# HG changeset patch # User Andrii Verbytskyi # Date 2024-10-09 14:58:32 # Node ID 50cf7822bd80eb2111765202797f20937bffb82b # Parent 112c3403e5ce3301bd7e910fb76d49aeaa9d49a4 # Parent 1574792eb17a7181c32e0378cd94be7d7d8bc781 chore: fixed merge conflicts diff --git a/.bumpversion.cfg b/.bumpversion.cfg --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 5.1.2 +current_version = 5.2.0 message = release: Bump version {current_version} to {new_version} [bumpversion:file:rhodecode/VERSION] diff --git a/.hgignore b/.hgignore --- a/.hgignore +++ b/.hgignore @@ -54,7 +54,7 @@ syntax: regexp ^rhodecode\.log$ ^rhodecode_dev\.log$ ^test\.db$ - +^venv/ # ac-tests ^acceptance_tests/\.cache.*$ diff --git a/Makefile b/Makefile --- a/Makefile +++ b/Makefile @@ -1,12 +1,49 @@ +.DEFAULT_GOAL := help + +# Pretty print values cf. https://misc.flogisoft.com/bash/tip_colors_and_formatting +RESET := \033[0m # Reset all formatting +GREEN := \033[0;32m # Resets before setting 16b colour (32 -- green) +YELLOW := \033[0;33m +ORANGE := \033[0;38;5;208m # Reset then set 256b colour (208 -- orange) +PEACH := \033[0;38;5;216m + + +## ---------------------------------------------------------------------------------- ## +## ------------------------- Help usage builder ------------------------------------- ## +## ---------------------------------------------------------------------------------- ## +# use '# >>> Build commands' to create section +# use '# target: target description' to create help for target +.PHONY: help +help: + @echo "Usage:" + @cat $(MAKEFILE_LIST) | grep -E '^# >>>|^# [A-Za-z0-9_.-]+:' | sed -E 's/^# //' | awk ' \ + BEGIN { \ + green="\033[32m"; \ + yellow="\033[33m"; \ + reset="\033[0m"; \ + section=""; \ + } \ + /^>>>/ { \ + section=substr($$0, 5); \ + printf "\n" green ">>> %s" reset "\n", section; \ + next; \ + } \ + /^([A-Za-z0-9_.-]+):/ { \ + target=$$1; \ + gsub(/:$$/, "", target); \ + description=substr($$0, index($$0, ":") + 2); \ + if (description == "") { description="-"; } \ + printf " - " yellow "%-35s" reset " %s\n", target, description; \ + } \ + ' + # required for pushd to work.. SHELL = /bin/bash - -# set by: PATH_TO_OUTDATED_PACKAGES=/some/path/outdated_packages.py -OUTDATED_PACKAGES = ${PATH_TO_OUTDATED_PACKAGES} +# >>> Tests commands .PHONY: clean -## Cleanup compiled and cache py files +# clean: Cleanup compiled and cache py files clean: make test-clean find . -type f \( -iname '*.c' -o -iname '*.pyc' -o -iname '*.so' -o -iname '*.orig' \) -exec rm '{}' ';' @@ -14,14 +51,14 @@ clean: .PHONY: test -## run test-clean and tests +# test: run test-clean and tests test: make test-clean - make test-only + unset RC_SQLALCHEMY_DB1_URL && unset RC_DB_URL && make test-only .PHONY: test-clean -## run test-clean and tests +# test-clean: run test-clean and tests test-clean: rm -rf coverage.xml htmlcov junit.xml pylint.log result find . -type d -name "__pycache__" -prune -exec rm -rf '{}' ';' @@ -29,34 +66,36 @@ test-clean: .PHONY: test-only -## Run tests only without cleanup +# test-only: Run tests only without cleanup test-only: PYTHONHASHSEED=random \ py.test -x -vv -r xw -p no:sugar \ --cov-report=term-missing --cov-report=html \ --cov=rhodecode rhodecode +# >>> Docs commands .PHONY: docs -## build docs +# docs: build docs docs: (cd docs; docker run --rm -v $(PWD):/project --workdir=/project/docs sphinx-doc-build-rc make clean html SPHINXOPTS="-W") .PHONY: docs-clean -## Cleanup docs +# docs-clean: Cleanup docs docs-clean: (cd docs; docker run --rm -v $(PWD):/project --workdir=/project/docs sphinx-doc-build-rc make clean) .PHONY: docs-cleanup -## Cleanup docs +# docs-cleanup: Cleanup docs docs-cleanup: (cd docs; docker run --rm -v $(PWD):/project --workdir=/project/docs sphinx-doc-build-rc make cleanup) +# >>> Dev commands .PHONY: web-build -## Build JS packages static/js +# web-build: Build JS packages static/js web-build: rm -rf node_modules docker run -it --rm -v $(PWD):/project --workdir=/project rhodecode/static-files-build:16 -c "npm install && /project/node_modules/.bin/grunt" @@ -64,25 +103,9 @@ web-build: ./rhodecode/tests/scripts/static-file-check.sh rhodecode/public/ rm -rf node_modules -.PHONY: ruff-check -## run a ruff analysis -ruff-check: - ruff check --ignore F401 --ignore I001 --ignore E402 --ignore E501 --ignore F841 --exclude rhodecode/lib/dbmigrate --exclude .eggs --exclude .dev . - -.PHONY: pip-packages -## Show outdated packages -pip-packages: - python ${OUTDATED_PACKAGES} - - -.PHONY: build -## Build sdist/egg -build: - python -m build - .PHONY: dev-sh -## make dev-sh +# dev-sh: make dev-sh dev-sh: sudo echo "deb [trusted=yes] https://apt.fury.io/rsteube/ /" | sudo tee -a "/etc/apt/sources.list.d/fury.list" sudo apt-get update @@ -95,14 +118,14 @@ dev-sh: .PHONY: dev-cleanup -## Cleanup: pip freeze | grep -v "^-e" | grep -v "@" | xargs pip uninstall -y +# dev-cleanup: Cleanup: pip freeze | grep -v "^-e" | grep -v "@" | xargs pip uninstall -y dev-cleanup: pip freeze | grep -v "^-e" | grep -v "@" | xargs pip uninstall -y rm -rf /tmp/* .PHONY: dev-env -## make dev-env based on the requirements files and install develop of packages +# dev-env: make dev-env based on the requirements files and install develop of packages ## Cleanup: pip freeze | grep -v "^-e" | grep -v "@" | xargs pip uninstall -y dev-env: sudo -u root chown rhodecode:rhodecode /home/rhodecode/.cache/pip/ @@ -114,7 +137,7 @@ dev-env: .PHONY: sh -## shortcut for make dev-sh dev-env +# sh: shortcut for make dev-sh dev-env sh: make dev-env make dev-sh @@ -124,49 +147,12 @@ sh: workers?=1 .PHONY: dev-srv -## run gunicorn web server with reloader, use workers=N to set multiworker mode +# dev-srv: run gunicorn web server with reloader, use workers=N to set multiworker mode, workers=N allows changes of workers dev-srv: gunicorn --paste=.dev/dev.ini --bind=0.0.0.0:10020 --config=.dev/gunicorn_config.py --timeout=120 --reload --workers=$(workers) - -# Default command on calling make -.DEFAULT_GOAL := show-help +.PHONY: ruff-check +# ruff-check: run a ruff analysis +ruff-check: + ruff check --ignore F401 --ignore I001 --ignore E402 --ignore E501 --ignore F841 --exclude rhodecode/lib/dbmigrate --exclude .eggs --exclude .dev . -.PHONY: show-help -show-help: - @echo "$$(tput bold)Available rules:$$(tput sgr0)" - @echo - @sed -n -e "/^## / { \ - h; \ - s/.*//; \ - :doc" \ - -e "H; \ - n; \ - s/^## //; \ - t doc" \ - -e "s/:.*//; \ - G; \ - s/\\n## /---/; \ - s/\\n/ /g; \ - p; \ - }" ${MAKEFILE_LIST} \ - | LC_ALL='C' sort --ignore-case \ - | awk -F '---' \ - -v ncol=$$(tput cols) \ - -v indent=19 \ - -v col_on="$$(tput setaf 6)" \ - -v col_off="$$(tput sgr0)" \ - '{ \ - printf "%s%*s%s ", col_on, -indent, $$1, col_off; \ - n = split($$2, words, " "); \ - line_length = ncol - indent; \ - for (i = 1; i <= n; i++) { \ - line_length -= length(words[i]) + 1; \ - if (line_length <= 0) { \ - line_length = ncol - indent - length(words[i]) - 1; \ - printf "\n%*s ", -indent, " "; \ - } \ - printf "%s ", words[i]; \ - } \ - printf "\n"; \ - }' diff --git a/configs/development.ini b/configs/development.ini --- a/configs/development.ini +++ b/configs/development.ini @@ -257,6 +257,13 @@ license_token = ; This flag hides sensitive information on the license page such as token, and license data license.hide_license_info = false +; Import EE license from this license path +#license.import_path = %(here)s/rhodecode_enterprise.license + +; import license 'if-missing' or 'force' (always override) +; if-missing means apply license if it doesn't exist. 'force' option always overrides it +license.import_path_mode = if-missing + ; supervisor connection uri, for managing supervisor and logs. supervisor.uri = @@ -281,15 +288,56 @@ labs_settings_active = true ; optional prefix to Add to email Subject #exception_tracker.email_prefix = [RHODECODE ERROR] -; File store configuration. This is used to store and serve uploaded files -file_store.enabled = true +; NOTE: this setting IS DEPRECATED: +; file_store backend is always enabled +#file_store.enabled = true +; NOTE: this setting IS DEPRECATED: +; file_store.backend = X -> use `file_store.backend.type = filesystem_v2` instead ; Storage backend, available options are: local -file_store.backend = local +#file_store.backend = local +; NOTE: this setting IS DEPRECATED: +; file_store.storage_path = X -> use `file_store.filesystem_v2.storage_path = X` instead ; path to store the uploaded binaries and artifacts -file_store.storage_path = /var/opt/rhodecode_data/file_store +#file_store.storage_path = /var/opt/rhodecode_data/file_store + +; Artifacts file-store, is used to store comment attachments and artifacts uploads. +; file_store backend type: filesystem_v1, filesystem_v2 or objectstore (s3-based) are available as options +; filesystem_v1 is backwards compat with pre 5.1 storage changes +; new installations should choose filesystem_v2 or objectstore (s3-based), pick filesystem when migrating from +; previous installations to keep the artifacts without a need of migration +#file_store.backend.type = filesystem_v2 + +; filesystem options... +#file_store.filesystem_v1.storage_path = /var/opt/rhodecode_data/artifacts_file_store + +; filesystem_v2 options... +#file_store.filesystem_v2.storage_path = /var/opt/rhodecode_data/artifacts_file_store +#file_store.filesystem_v2.shards = 8 +; objectstore options... +; url for s3 compatible storage that allows to upload artifacts +; e.g http://minio:9000 +#file_store.backend.type = objectstore +#file_store.objectstore.url = http://s3-minio:9000 + +; a top-level bucket to put all other shards in +; objects will be stored in rhodecode-file-store/shard-N based on the bucket_shards number +#file_store.objectstore.bucket = rhodecode-file-store + +; number of sharded buckets to create to distribute archives across +; default is 8 shards +#file_store.objectstore.bucket_shards = 8 + +; key for s3 auth +#file_store.objectstore.key = s3admin + +; secret for s3 auth +#file_store.objectstore.secret = s3secret4 + +;region for s3 storage +#file_store.objectstore.region = eu-central-1 ; Redis url to acquire/check generation of archives locks archive_cache.locking.url = redis://redis:6379/1 @@ -624,7 +672,8 @@ vcs.scm_app_implementation = http ; Push/Pull operations hooks protocol, available options are: ; `http` - use http-rpc backend (default) ; `celery` - use celery based hooks -vcs.hooks.protocol = http +#DEPRECATED:vcs.hooks.protocol = http +vcs.hooks.protocol.v2 = celery ; Host on which this instance is listening for hooks. vcsserver will call this host to pull/push hooks so it should be ; accessible via network. @@ -647,6 +696,12 @@ vcs.connection_timeout = 3600 ; It uses cache_region `cache_repo` vcs.methods.cache = true +; Filesystem location where Git lfs objects should be stored +vcs.git.lfs.storage_location = /var/opt/rhodecode_repo_store/.cache/git_lfs_store + +; Filesystem location where Mercurial largefile objects should be stored +vcs.hg.largefiles.storage_location = /var/opt/rhodecode_repo_store/.cache/hg_largefiles_store + ; #################################################### ; Subversion proxy support (mod_dav_svn) ; Maps RhodeCode repo groups into SVN paths for Apache @@ -716,7 +771,8 @@ ssh.authorized_keys_file_path = /etc/rho ; RhodeCode installation directory. ; legacy: /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper ; new rewrite: /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper-v2 -ssh.wrapper_cmd = /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper +#DEPRECATED: ssh.wrapper_cmd = /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper +ssh.wrapper_cmd.v2 = /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper-v2 ; Allow shell when executing the ssh-wrapper command ssh.wrapper_cmd_allow_shell = false diff --git a/configs/gunicorn_config.py b/configs/gunicorn_config.py --- a/configs/gunicorn_config.py +++ b/configs/gunicorn_config.py @@ -13,6 +13,7 @@ import traceback import random import socket import dataclasses +import json from gunicorn.glogging import Logger @@ -37,17 +38,41 @@ accesslog = '-' worker_tmp_dir = None tmp_upload_dir = None -# use re-use port logic -#reuse_port = True +# use re-use port logic to let linux internals load-balance the requests better. +reuse_port = True # Custom log format #access_log_format = ( # '%(t)s %(p)s INFO [GNCRN] %(h)-15s rqt:%(L)s %(s)s %(b)-6s "%(m)s:%(U)s %(q)s" usr:%(u)s "%(f)s" "%(a)s"') # loki format for easier parsing in grafana -access_log_format = ( +loki_access_log_format = ( 'time="%(t)s" pid=%(p)s level="INFO" type="[GNCRN]" ip="%(h)-15s" rqt="%(L)s" response_code="%(s)s" response_bytes="%(b)-6s" uri="%(m)s:%(U)s %(q)s" user=":%(u)s" user_agent="%(a)s"') +# JSON format +json_access_log_format = json.dumps({ + 'time': r'%(t)s', + 'pid': r'%(p)s', + 'level': 'INFO', + 'ip': r'%(h)s', + 'request_time': r'%(L)s', + 'remote_address': r'%(h)s', + 'user_name': r'%(u)s', + 'status': r'%(s)s', + 'method': r'%(m)s', + 'url_path': r'%(U)s', + 'query_string': r'%(q)s', + 'protocol': r'%(H)s', + 'response_length': r'%(B)s', + 'referer': r'%(f)s', + 'user_agent': r'%(a)s', + +}) + +access_log_format = loki_access_log_format +if os.environ.get('RC_LOGGING_FORMATTER') == 'json': + access_log_format = json_access_log_format + # self adjust workers based on CPU count, to use maximum of CPU and not overquota the resources # workers = get_workers() diff --git a/configs/init.d/.readme.txt b/configs/init.d/.readme.txt deleted file mode 100644 --- a/configs/init.d/.readme.txt +++ /dev/null @@ -1,1 +0,0 @@ -Example init scripts. \ No newline at end of file diff --git a/configs/init.d/supervisord.conf b/configs/init.d/supervisord.conf deleted file mode 100644 --- a/configs/init.d/supervisord.conf +++ /dev/null @@ -1,61 +0,0 @@ -; Sample supervisor RhodeCode config file. -; -; For more information on the config file, please see: -; http://supervisord.org/configuration.html -; -; Note: shell expansion ("~" or "$HOME") is not supported. Environment -; variables can be expanded using this syntax: "%(ENV_HOME)s". - -[unix_http_server] -file=/tmp/supervisor.sock ; (the path to the socket file) -;chmod=0700 ; socket file mode (default 0700) -;chown=nobody:nogroup ; socket file uid:gid owner -;username=user ; (default is no username (open server)) -;password=123 ; (default is no password (open server)) - -[supervisord] -logfile=/home/ubuntu/rhodecode/supervisord.log ; (main log file;default $CWD/supervisord.log) -logfile_maxbytes=50MB ; (max main logfile bytes b4 rotation;default 50MB) -logfile_backups=10 ; (num of main logfile rotation backups;default 10) -loglevel=info ; (log level;default info; others: debug,warn,trace) -pidfile=/home/ubuntu/rhodecode/supervisord.pid ; (supervisord pidfile;default supervisord.pid) -nodaemon=true ; (start in foreground if true;default false) -minfds=1024 ; (min. avail startup file descriptors;default 1024) -minprocs=200 ; (min. avail process descriptors;default 200) -;umask=022 ; (process file creation umask;default 022) -user=ubuntu ; (default is current user, required if root) -;identifier=supervisor ; (supervisord identifier, default is 'supervisor') -;directory=/tmp ; (default is not to cd during start) -;nocleanup=true ; (don't clean up tempfiles at start;default false) -;childlogdir=/tmp ; ('AUTO' child log dir, default $TEMP) -environment=HOME=/home/ubuntu,LANG=en_US.UTF-8 ; (key value pairs to add to environment) -;strip_ansi=false ; (strip ansi escape codes in logs; def. false) - -; the below section must remain in the config file for RPC -; (supervisorctl/web interface) to work, additional interfaces may be -; added by defining them in separate rpcinterface: sections -[rpcinterface:supervisor] -supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface - -[supervisorctl] -serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket -;username=chris ; should be same as http_username if set -;password=123 ; should be same as http_password if set - - -; restart with supervisorctl restart rhodecode:* -[program:rhodecode] -numprocs = 1 -numprocs_start = 5000 -directory=/home/ubuntu/rhodecode/source -command = /home/ubuntu/rhodecode/venv/bin/paster serve /home/ubuntu/rhodecode/source/prod.ini -process_name = %(program_name)s_%(process_num)04d -redirect_stderr=true -stdout_logfile=/home/ubuntu/rhodecode/rhodecode.log - -[program:rhodecode_workers] -numproces = 1 -directory = /home/ubuntu/rhodecode/source -command = /home/ubuntu/rhodecode/venv/bin/paster celeryd /home/ubuntu/rhodecode/source/prod.ini --autoscale=10,2 -redirect_stderr=true -stdout_logfile=/%(here)s/rhodecode_workers.log diff --git a/configs/production.ini b/configs/production.ini --- a/configs/production.ini +++ b/configs/production.ini @@ -225,6 +225,13 @@ license_token = ; This flag hides sensitive information on the license page such as token, and license data license.hide_license_info = false +; Import EE license from this license path +#license.import_path = %(here)s/rhodecode_enterprise.license + +; import license 'if-missing' or 'force' (always override) +; if-missing means apply license if it doesn't exist. 'force' option always overrides it +license.import_path_mode = if-missing + ; supervisor connection uri, for managing supervisor and logs. supervisor.uri = @@ -249,15 +256,56 @@ labs_settings_active = true ; optional prefix to Add to email Subject #exception_tracker.email_prefix = [RHODECODE ERROR] -; File store configuration. This is used to store and serve uploaded files -file_store.enabled = true +; NOTE: this setting IS DEPRECATED: +; file_store backend is always enabled +#file_store.enabled = true +; NOTE: this setting IS DEPRECATED: +; file_store.backend = X -> use `file_store.backend.type = filesystem_v2` instead ; Storage backend, available options are: local -file_store.backend = local +#file_store.backend = local +; NOTE: this setting IS DEPRECATED: +; file_store.storage_path = X -> use `file_store.filesystem_v2.storage_path = X` instead ; path to store the uploaded binaries and artifacts -file_store.storage_path = /var/opt/rhodecode_data/file_store +#file_store.storage_path = /var/opt/rhodecode_data/file_store + +; Artifacts file-store, is used to store comment attachments and artifacts uploads. +; file_store backend type: filesystem_v1, filesystem_v2 or objectstore (s3-based) are available as options +; filesystem_v1 is backwards compat with pre 5.1 storage changes +; new installations should choose filesystem_v2 or objectstore (s3-based), pick filesystem when migrating from +; previous installations to keep the artifacts without a need of migration +#file_store.backend.type = filesystem_v2 + +; filesystem options... +#file_store.filesystem_v1.storage_path = /var/opt/rhodecode_data/artifacts_file_store + +; filesystem_v2 options... +#file_store.filesystem_v2.storage_path = /var/opt/rhodecode_data/artifacts_file_store +#file_store.filesystem_v2.shards = 8 +; objectstore options... +; url for s3 compatible storage that allows to upload artifacts +; e.g http://minio:9000 +#file_store.backend.type = objectstore +#file_store.objectstore.url = http://s3-minio:9000 + +; a top-level bucket to put all other shards in +; objects will be stored in rhodecode-file-store/shard-N based on the bucket_shards number +#file_store.objectstore.bucket = rhodecode-file-store + +; number of sharded buckets to create to distribute archives across +; default is 8 shards +#file_store.objectstore.bucket_shards = 8 + +; key for s3 auth +#file_store.objectstore.key = s3admin + +; secret for s3 auth +#file_store.objectstore.secret = s3secret4 + +;region for s3 storage +#file_store.objectstore.region = eu-central-1 ; Redis url to acquire/check generation of archives locks archive_cache.locking.url = redis://redis:6379/1 @@ -592,7 +640,8 @@ vcs.scm_app_implementation = http ; Push/Pull operations hooks protocol, available options are: ; `http` - use http-rpc backend (default) ; `celery` - use celery based hooks -vcs.hooks.protocol = http +#DEPRECATED:vcs.hooks.protocol = http +vcs.hooks.protocol.v2 = celery ; Host on which this instance is listening for hooks. vcsserver will call this host to pull/push hooks so it should be ; accessible via network. @@ -615,6 +664,12 @@ vcs.connection_timeout = 3600 ; It uses cache_region `cache_repo` vcs.methods.cache = true +; Filesystem location where Git lfs objects should be stored +vcs.git.lfs.storage_location = /var/opt/rhodecode_repo_store/.cache/git_lfs_store + +; Filesystem location where Mercurial largefile objects should be stored +vcs.hg.largefiles.storage_location = /var/opt/rhodecode_repo_store/.cache/hg_largefiles_store + ; #################################################### ; Subversion proxy support (mod_dav_svn) ; Maps RhodeCode repo groups into SVN paths for Apache @@ -684,7 +739,8 @@ ssh.authorized_keys_file_path = /etc/rho ; RhodeCode installation directory. ; legacy: /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper ; new rewrite: /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper-v2 -ssh.wrapper_cmd = /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper +#DEPRECATED: ssh.wrapper_cmd = /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper +ssh.wrapper_cmd.v2 = /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper-v2 ; Allow shell when executing the ssh-wrapper command ssh.wrapper_cmd_allow_shell = false diff --git a/docs/Dockerfile b/docs/Dockerfile --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -22,6 +22,12 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \ + unzip awscliv2.zip && \ + ./aws/install && \ + rm -rf ./aws && \ + rm awscliv2.zip + RUN \ python3 -m pip install --no-cache-dir --upgrade pip && \ python3 -m pip install --no-cache-dir Sphinx Pillow diff --git a/docs/admin/system-overview.rst b/docs/admin/system-overview.rst --- a/docs/admin/system-overview.rst +++ b/docs/admin/system-overview.rst @@ -147,10 +147,6 @@ Peer-to-peer Failover Support * Yes -Additional Binaries -------------------- - -* Yes, see :ref:`rhodecode-nix-ref` for full details. Remote Connectivity ------------------- diff --git a/docs/auth/auth-saml-azure.rst b/docs/auth/auth-saml-azure.rst new file mode 100644 --- /dev/null +++ b/docs/auth/auth-saml-azure.rst @@ -0,0 +1,161 @@ +.. _config-saml-azure-ref: + + +SAML 2.0 with Azure Entra ID +---------------------------- + +**This plugin is available only in EE Edition.** + +|RCE| supports SAML 2.0 Authentication with Azure Entra ID provider. This allows +users to log-in to RhodeCode via SSO mechanism of external identity provider +such as Azure AD. The login can be triggered either by the external IDP, or internally +by clicking specific authentication button on the log-in page. + + +Configuration steps +^^^^^^^^^^^^^^^^^^^ + +To configure Duo Security SAML authentication, use the following steps: + +1. From the |RCE| interface, select + :menuselection:`Admin --> Authentication` +2. Activate the `Azure Entra ID` plugin and select :guilabel:`Save` +3. Go to newly available menu option called `Azure Entra ID` on the left side. +4. Check the `enabled` check box in the plugin configuration section, + and fill in the required SAML information and :guilabel:`Save`, for more details, + see :ref:`config-saml-azure` + + +.. _config-saml-azure: + + +Example SAML Azure Entra ID configuration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Example configuration for SAML 2.0 with Azure Entra ID provider + + +Enabled + `True`: + + .. note:: + Enable or disable this authentication plugin. + + +Auth Cache TTL + `30`: + + .. note:: + Amount of seconds to cache the authentication and permissions check response call for this plugin. + Useful for expensive calls like LDAP to improve the performance of the system (0 means disabled). + +Debug + `True`: + + .. note:: + Enable or disable debug mode that shows SAML errors in the RhodeCode logs. + + +Auth button name + `Azure Entra ID`: + + .. note:: + Alternative authentication display name. E.g AzureAuth, CorporateID etc. + + +Entity ID + `https://sts.windows.net/APP_ID/`: + + .. note:: + Identity Provider entity/metadata URI. Known as "Microsoft Entra Identifier" + E.g. https://sts.windows.net/abcd-c655-dcee-aab7-abcd/ + +SSO URL + `https://login.microsoftonline.com/APP_ID/saml2`: + + .. note:: + SSO (SingleSignOn) endpoint URL of the IdP. This can be used to initialize login, Known also as Login URL + E.g. https://login.microsoftonline.com/abcd-c655-dcee-aab7-abcd/saml2 + +SLO URL + `https://login.microsoftonline.com/APP_ID/saml2`: + + .. note:: + SLO (SingleLogout) endpoint URL of the IdP. , Known also as Logout URL + E.g. https://login.microsoftonline.com/abcd-c655-dcee-aab7-abcd/saml2 + +x509cert + ``: + + .. note:: + Identity provider public x509 certificate. It will be converted to single-line format without headers. + Download the raw base64 encoded certificate from the Identity provider and paste it here. + +SAML Signature + `sha-256`: + + .. note:: + Type of Algorithm to use for verification of SAML signature on Identity provider side. + +SAML Digest + `sha-256`: + + .. note:: + Type of Algorithm to use for verification of SAML digest on Identity provider side. + +Service Provider Cert Dir + `/etc/rhodecode/conf/saml_ssl/`: + + .. note:: + Optional directory to store service provider certificate and private keys. + Expected certs for the SP should be stored in this folder as: + + * sp.key Private Key + * sp.crt Public cert + * sp_new.crt Future Public cert + + Also you can use other cert to sign the metadata of the SP using the: + + * metadata.key + * metadata.crt + +Expected NameID Format + `nameid-format:emailAddress`: + + .. note:: + The format that specifies how the NameID is sent to the service provider. + +User ID Attribute + `user.email`: + + .. note:: + User ID Attribute name. This defines which attribute in SAML response will be used to link accounts via unique id. + Ensure this is returned from DuoSecurity for example via duo_username. + +Username Attribute + `user.username`: + + .. note:: + Username Attribute name. This defines which attribute in SAML response will map to a username. + +Email Attribute + `user.email`: + + .. note:: + Email Attribute name. This defines which attribute in SAML response will map to an email address. + + + +Below is example setup from Azure Administration page that can be used with above config. + +.. image:: ../images/saml-azure-service-provider-example.png + :alt: Azure SAML setup example + :scale: 50 % + + +Below is an example attribute mapping set for IDP provider required by the above config. + + +.. image:: ../images/saml-azure-attributes-example.png + :alt: Azure SAML setup example + :scale: 50 % \ No newline at end of file diff --git a/docs/auth/auth-saml-bulk-enroll-users.rst b/docs/auth/auth-saml-bulk-enroll-users.rst --- a/docs/auth/auth-saml-bulk-enroll-users.rst +++ b/docs/auth/auth-saml-bulk-enroll-users.rst @@ -13,7 +13,7 @@ This method simply enables SAML authenti From the server RhodeCode Enterprise is running run ishell on the instance which we want to apply the SAML migration:: - rccontrol ishell enterprise-1 + ./rcstack cli ishell Follow these steps to enable SAML authentication for multiple users. @@ -46,6 +46,8 @@ From available options pick only one and # for Duo Security In [2]: from rc_auth_plugins.auth_duo_security import RhodeCodeAuthPlugin + # for Azure Entra + In [2]: from rc_auth_plugins.auth_azure import RhodeCodeAuthPlugin # for OneLogin In [2]: from rc_auth_plugins.auth_onelogin import RhodeCodeAuthPlugin # generic SAML plugin @@ -62,13 +64,13 @@ Enter in the ishell prompt ...: attrs = saml2user.get(user.user_id) ...: provider = RhodeCodeAuthPlugin.uid ...: if existing_identity: - ...: print('Identity for user `{}` already exists, skipping'.format(user.username)) + ...: print(f'Identity for user `{user.username}` already exists, skipping') ...: continue ...: if attrs: ...: external_id = attrs['id'] ...: new_external_identity = ExternalIdentity() ...: new_external_identity.external_id = external_id - ...: new_external_identity.external_username = '{}-saml-{}'.format(user.username, user.user_id) + ...: new_external_identity.external_username = f'{user.username}-saml-{user.user_id}' ...: new_external_identity.provider_name = provider ...: new_external_identity.local_user_id = user.user_id ...: new_external_identity.access_token = '' @@ -76,7 +78,7 @@ Enter in the ishell prompt ...: new_external_identity.alt_token = '' ...: Session().add(ex_identity) ...: Session().commit() - ...: print('Set user `{}` external identity bound to ExternalID:{}'.format(user.username, external_id)) + ...: print(f'Set user `{user.username}` external identity bound to ExternalID:{external_id}') .. note:: diff --git a/docs/auth/auth-saml-duosecurity.rst b/docs/auth/auth-saml-duosecurity.rst --- a/docs/auth/auth-saml-duosecurity.rst +++ b/docs/auth/auth-saml-duosecurity.rst @@ -32,62 +32,118 @@ 4. Check the `enabled` check box in the Example SAML Duo Security configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Example configuration for SAML 2.0 with Duo Security provider:: +Example configuration for SAML 2.0 with Duo Security provider + + +Enabled + `True`: - *option*: `enabled` => `True` - # Enable or disable this authentication plugin. + .. note:: + Enable or disable this authentication plugin. + + +Auth Cache TTL + `30`: - *option*: `cache_ttl` => `0` - # Amount of seconds to cache the authentication and permissions check response call for this plugin. - # Useful for expensive calls like LDAP to improve the performance of the system (0 means disabled). + .. note:: + Amount of seconds to cache the authentication and permissions check response call for this plugin. + Useful for expensive calls like LDAP to improve the performance of the system (0 means disabled). + +Debug + `True`: - *option*: `debug` => `True` - # Enable or disable debug mode that shows SAML errors in the RhodeCode logs. + .. note:: + Enable or disable debug mode that shows SAML errors in the RhodeCode logs. + + +Auth button name + `Azure Entra ID`: - *option*: `entity_id` => `http://rc-app.com/dag/saml2/idp/metadata.php` - # Identity Provider entity/metadata URI. - # E.g. https://duo-gateway.com/dag/saml2/idp/metadata.php + .. note:: + Alternative authentication display name. E.g AzureAuth, CorporateID etc. + + +Entity ID + `https://my-duo-gateway.com/dag/saml2/idp/metadata.php`: + + .. note:: + Identity Provider entity/metadata URI. + E.g. https://duo-gateway.com/dag/saml2/idp/metadata.php + +SSO URL + `https://duo-gateway.com/dag/saml2/idp/SSOService.php?spentityid=`: - *option*: `sso_service_url` => `http://rc-app.com/dag/saml2/idp/SSOService.php?spentityid=http://rc.local.pl/_admin/auth/duosecurity/saml-metadata` - # SSO (SingleSignOn) endpoint URL of the IdP. This can be used to initialize login - # E.g. https://duo-gateway.com/dag/saml2/idp/SSOService.php?spentityid= + .. note:: + SSO (SingleSignOn) endpoint URL of the IdP. This can be used to initialize login, Known also as Login URL + E.g. http://rc-app.com/dag/saml2/idp/SSOService.php?spentityid=https://docker-dev/_admin/auth/duosecurity/saml-metadata + +SLO URL + `https://duo-gateway.com/dag/saml2/idp/SingleLogoutService.php?ReturnTo=`: - *option*: `slo_service_url` => `http://rc-app.com/dag/saml2/idp/SingleLogoutService.php?ReturnTo=http://rc-app.com/dag/module.php/duosecurity/logout.php` - # SLO (SingleLogout) endpoint URL of the IdP. - # E.g. https://duo-gateway.com/dag/saml2/idp/SingleLogoutService.php?ReturnTo=http://duo-gateway.com/_admin/saml/sign-out-endpoint + .. note:: + SLO (SingleLogout) endpoint URL of the IdP. , Known also as Logout URL + E.g. http://rc-app.com/dag/saml2/idp/SingleLogoutService.php?ReturnTo=https://docker-dev/_admin/auth/duosecurity/saml-sign-out-endpoint - *option*: `x509cert` => `` - # Identity provider public x509 certificate. It will be converted to single-line format without headers +x509cert + ``: - *option*: `name_id_format` => `sha-1` - # The format that specifies how the NameID is sent to the service provider. + .. note:: + Identity provider public x509 certificate. It will be converted to single-line format without headers. + Download the raw base64 encoded certificate from the Identity provider and paste it here. + +SAML Signature + `sha-256`: + + .. note:: + Type of Algorithm to use for verification of SAML signature on Identity provider side. + +SAML Digest + `sha-256`: - *option*: `signature_algo` => `sha-256` - # Type of Algorithm to use for verification of SAML signature on Identity provider side + .. note:: + Type of Algorithm to use for verification of SAML digest on Identity provider side. + +Service Provider Cert Dir + `/etc/rhodecode/conf/saml_ssl/`: - *option*: `digest_algo` => `sha-256` - # Type of Algorithm to use for verification of SAML digest on Identity provider side + .. note:: + Optional directory to store service provider certificate and private keys. + Expected certs for the SP should be stored in this folder as: + + * sp.key Private Key + * sp.crt Public cert + * sp_new.crt Future Public cert + + Also you can use other cert to sign the metadata of the SP using the: - *option*: `cert_dir` => `/etc/saml/` - # Optional directory to store service provider certificate and private keys. - # Expected certs for the SP should be stored in this folder as: - # * sp.key Private Key - # * sp.crt Public cert - # * sp_new.crt Future Public cert - # - # Also you can use other cert to sign the metadata of the SP using the: - # * metadata.key - # * metadata.crt + * metadata.key + * metadata.crt + +Expected NameID Format + `nameid-format:emailAddress`: + + .. note:: + The format that specifies how the NameID is sent to the service provider. + +User ID Attribute + `PersonImmutableID`: - *option*: `user_id_attribute` => `PersonImmutableID` - # User ID Attribute name. This defines which attribute in SAML response will be used to link accounts via unique id. - # Ensure this is returned from DuoSecurity for example via duo_username + .. note:: + User ID Attribute name. This defines which attribute in SAML response will be used to link accounts via unique id. + Ensure this is returned from DuoSecurity for example via duo_username. + +Username Attribute + `User.username`: - *option*: `username_attribute` => `User.username` - # Username Attribute name. This defines which attribute in SAML response will map to an username. + .. note:: + Username Attribute name. This defines which attribute in SAML response will map to a username. - *option*: `email_attribute` => `User.email` - # Email Attribute name. This defines which attribute in SAML response will map to an email address. +Email Attribute + `User.email`: + + .. note:: + Email Attribute name. This defines which attribute in SAML response will map to an email address. + Below is example setup from DUO Administration page that can be used with above config. diff --git a/docs/auth/auth-saml-generic.rst b/docs/auth/auth-saml-generic.rst --- a/docs/auth/auth-saml-generic.rst +++ b/docs/auth/auth-saml-generic.rst @@ -15,5 +15,6 @@ Please check for reference two example p auth-saml-duosecurity auth-saml-onelogin + auth-saml-azure auth-saml-bulk-enroll-users diff --git a/docs/auth/auth-saml-onelogin.rst b/docs/auth/auth-saml-onelogin.rst --- a/docs/auth/auth-saml-onelogin.rst +++ b/docs/auth/auth-saml-onelogin.rst @@ -32,62 +32,117 @@ 4. Check the `enabled` check box in the Example SAML OneLogin configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Example configuration for SAML 2.0 with OneLogin provider:: +Example configuration for SAML 2.0 with OneLogin provider + + +Enabled + `True`: - *option*: `enabled` => `True` - # Enable or disable this authentication plugin. + .. note:: + Enable or disable this authentication plugin. + + +Auth Cache TTL + `30`: - *option*: `cache_ttl` => `0` - # Amount of seconds to cache the authentication and permissions check response call for this plugin. - # Useful for expensive calls like LDAP to improve the performance of the system (0 means disabled). + .. note:: + Amount of seconds to cache the authentication and permissions check response call for this plugin. + Useful for expensive calls like LDAP to improve the performance of the system (0 means disabled). + +Debug + `True`: - *option*: `debug` => `True` - # Enable or disable debug mode that shows SAML errors in the RhodeCode logs. + .. note:: + Enable or disable debug mode that shows SAML errors in the RhodeCode logs. + + +Auth button name + `Azure Entra ID`: - *option*: `entity_id` => `https://app.onelogin.com/saml/metadata/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` - # Identity Provider entity/metadata URI. - # E.g. https://app.onelogin.com/saml/metadata/ + .. note:: + Alternative authentication display name. E.g AzureAuth, CorporateID etc. + + +Entity ID + `https://app.onelogin.com/saml/metadata/`: + + .. note:: + Identity Provider entity/metadata URI. + E.g. https://app.onelogin.com/saml/metadata/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +SSO URL + `https://app.onelogin.com/trust/saml2/http-post/sso/`: - *option*: `sso_service_url` => `https://customer-domain.onelogin.com/trust/saml2/http-post/sso/xxxxxx` - # SSO (SingleSignOn) endpoint URL of the IdP. This can be used to initialize login - # E.g. https://app.onelogin.com/trust/saml2/http-post/sso/ + .. note:: + SSO (SingleSignOn) endpoint URL of the IdP. This can be used to initialize login, Known also as Login URL + E.g. https://app.onelogin.com/trust/saml2/http-post/sso/ + +SLO URL + `https://app.onelogin.com/trust/saml2/http-redirect/slo/`: - *option*: `slo_service_url` => `https://customer-domain.onelogin.com/trust/saml2/http-redirect/slo/xxxxxx` - # SLO (SingleLogout) endpoint URL of the IdP. - # E.g. https://app.onelogin.com/trust/saml2/http-redirect/slo/ + .. note:: + SLO (SingleLogout) endpoint URL of the IdP. , Known also as Logout URL + E.g. https://app.onelogin.com/trust/saml2/http-redirect/slo/ - *option*: `x509cert` => `` - # Identity provider public x509 certificate. It will be converted to single-line format without headers +x509cert + ``: - *option*: `name_id_format` => `sha-1` - # The format that specifies how the NameID is sent to the service provider. + .. note:: + Identity provider public x509 certificate. It will be converted to single-line format without headers. + Download the raw base64 encoded certificate from the Identity provider and paste it here. + +SAML Signature + `sha-256`: + + .. note:: + Type of Algorithm to use for verification of SAML signature on Identity provider side. + +SAML Digest + `sha-256`: - *option*: `signature_algo` => `sha-256` - # Type of Algorithm to use for verification of SAML signature on Identity provider side + .. note:: + Type of Algorithm to use for verification of SAML digest on Identity provider side. + +Service Provider Cert Dir + `/etc/rhodecode/conf/saml_ssl/`: - *option*: `digest_algo` => `sha-256` - # Type of Algorithm to use for verification of SAML digest on Identity provider side + .. note:: + Optional directory to store service provider certificate and private keys. + Expected certs for the SP should be stored in this folder as: + + * sp.key Private Key + * sp.crt Public cert + * sp_new.crt Future Public cert - *option*: `cert_dir` => `/etc/saml/` - # Optional directory to store service provider certificate and private keys. - # Expected certs for the SP should be stored in this folder as: - # * sp.key Private Key - # * sp.crt Public cert - # * sp_new.crt Future Public cert - # - # Also you can use other cert to sign the metadata of the SP using the: - # * metadata.key - # * metadata.crt + Also you can use other cert to sign the metadata of the SP using the: + + * metadata.key + * metadata.crt + +Expected NameID Format + `nameid-format:emailAddress`: + + .. note:: + The format that specifies how the NameID is sent to the service provider. + +User ID Attribute + `PersonImmutableID`: - *option*: `user_id_attribute` => `PersonImmutableID` - # User ID Attribute name. This defines which attribute in SAML response will be used to link accounts via unique id. - # Ensure this is returned from OneLogin for example via Internal ID + .. note:: + User ID Attribute name. This defines which attribute in SAML response will be used to link accounts via unique id. + Ensure this is returned from DuoSecurity for example via duo_username. + +Username Attribute + `User.username`: - *option*: `username_attribute` => `User.username` - # Username Attribute name. This defines which attribute in SAML response will map to an username. + .. note:: + Username Attribute name. This defines which attribute in SAML response will map to a username. - *option*: `email_attribute` => `User.email` - # Email Attribute name. This defines which attribute in SAML response will map to an email address. +Email Attribute + `User.email`: + + .. note:: + Email Attribute name. This defines which attribute in SAML response will map to an email address. diff --git a/docs/auth/auth.rst b/docs/auth/auth.rst --- a/docs/auth/auth.rst +++ b/docs/auth/auth.rst @@ -29,6 +29,7 @@ administrator greater control over how u auth-saml-generic auth-saml-onelogin auth-saml-duosecurity + auth-saml-azure auth-crowd auth-pam ssh-connection diff --git a/docs/contributing/dev-setup.rst b/docs/contributing/dev-setup.rst --- a/docs/contributing/dev-setup.rst +++ b/docs/contributing/dev-setup.rst @@ -4,237 +4,8 @@ Development setup =================== - -RhodeCode Enterprise runs inside a Nix managed environment. This ensures build -environment dependencies are correctly declared and installed during setup. -It also enables atomic upgrades, rollbacks, and multiple instances of RhodeCode -Enterprise running with isolation. - -To set up RhodeCode Enterprise inside the Nix environment, use the following steps: - - - -Setup Nix Package Manager -------------------------- - -To install the Nix Package Manager, please run:: - - $ curl https://releases.nixos.org/nix/nix-2.3.4/install | sh - -or go to https://nixos.org/nix/ and follow the installation instructions. -Once this is correctly set up on your system, you should be able to use the -following commands: - -* `nix-env` - -* `nix-shell` - - -.. tip:: - - Update your channels frequently by running ``nix-channel --update``. - -.. note:: - - To uninstall nix run the following: - - remove the . "$HOME/.nix-profile/etc/profile.d/nix.sh" line in your ~/.profile or ~/.bash_profile - rm -rf $HOME/{.nix-channels,.nix-defexpr,.nix-profile,.config/nixpkgs} - sudo rm -rf /nix - -Switch nix to the latest STABLE channel ---------------------------------------- - -run:: - - nix-channel --add https://nixos.org/channels/nixos-20.03 nixpkgs - -Followed by:: - - nix-channel --update - nix-env -i nix-2.3.4 - - -Install required binaries -------------------------- - -We need some handy tools first. - -run:: - - nix-env -i nix-prefetch-hg - nix-env -i nix-prefetch-git - - -Speed up JS build by installing PhantomJS ------------------------------------------ - -PhantomJS will be downloaded each time nix-shell is invoked. To speed this by -setting already downloaded version do this:: - - nix-env -i phantomjs-2.1.1 - - # and set nix bin path - export PATH=$PATH:~/.nix-profile/bin - - -Clone the required repositories -------------------------------- - -After Nix is set up, clone the RhodeCode Enterprise Community Edition and -RhodeCode VCSServer repositories into the same directory. -RhodeCode currently is using Mercurial Version Control System, please make sure -you have it installed before continuing. - -To obtain the required sources, use the following commands:: - - mkdir rhodecode-develop && cd rhodecode-develop - hg clone -u default https://code.rhodecode.com/rhodecode-enterprise-ce - hg clone -u default https://code.rhodecode.com/rhodecode-vcsserver - -.. note:: - - If you cannot clone the repository, please contact us via support@rhodecode.com - - -Install some required libraries -------------------------------- - -There are some required drivers and dev libraries that we need to install to -test RhodeCode under different types of databases. For example in Ubuntu we -need to install the following. - -required libraries:: - - # svn related - sudo apt-get install libapr1-dev libaprutil1-dev - sudo apt-get install libsvn-dev - # libcurl required too - sudo apt-get install libcurl4-openssl-dev - # mysql/pg server for development, optional - sudo apt-get install mysql-server libmysqlclient-dev - sudo apt-get install postgresql postgresql-contrib libpq-dev - - - -Enter the Development Shell ---------------------------- - -The final step is to start the development shells. To do this, run the -following command from inside the cloned repository:: - - # first, the vcsserver - cd ~/rhodecode-vcsserver - nix-shell - - # then enterprise sources - cd ~/rhodecode-enterprise-ce - nix-shell - -.. note:: - - On the first run, this will take a while to download and optionally compile - a few things. The following runs will be faster. The development shell works - fine on both MacOS and Linux platforms. - - -Create config.nix for development ---------------------------------- - -In order to run proper tests and setup linking across projects, a config.nix -file needs to be setup:: - - # create config - mkdir -p ~/.nixpkgs - touch ~/.nixpkgs/config.nix - - # put the below content into the ~/.nixpkgs/config.nix file - # adjusts, the path to where you cloned your repositories. - - { - rc = { - sources = { - rhodecode-vcsserver = "/home/dev/rhodecode-vcsserver"; - rhodecode-enterprise-ce = "/home/dev/rhodecode-enterprise-ce"; - rhodecode-enterprise-ee = "/home/dev/rhodecode-enterprise-ee"; - }; - }; - } - - - -Creating a Development Configuration ------------------------------------- - -To create a development environment for RhodeCode Enterprise, -use the following steps: - -1. Create a copy of vcsserver config: - `cp ~/rhodecode-vcsserver/configs/development.ini ~/rhodecode-vcsserver/configs/dev.ini` -2. Create a copy of rhodocode config: - `cp ~/rhodecode-enterprise-ce/configs/development.ini ~/rhodecode-enterprise-ce/configs/dev.ini` -3. Adjust the configuration settings to your needs if needed. - -.. note:: - - It is recommended to use the name `dev.ini` since it's included in .hgignore file. - - -Setup the Development Database -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -To create a development database, use the following example. This is a one -time operation executed from the nix-shell of rhodecode-enterprise-ce sources :: - - rc-setup-app dev.ini \ - --user=admin --password=secret \ - --email=admin@example.com \ - --repos=~/my_dev_repos - - -Compile CSS and JavaScript -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -To use the application's frontend and prepare it for production deployment, -you will need to compile the CSS and JavaScript with Grunt. -This is easily done from within the nix-shell using the following command:: - - make web-build - -When developing new features you will need to recompile following any -changes made to the CSS or JavaScript files when developing the code:: - - grunt watch - -This prepares the development (with comments/whitespace) versions of files. - -Start the Development Servers -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -From the rhodecode-vcsserver directory, start the development server in another -nix-shell, using the following command:: - - pserve configs/dev.ini - -In the adjacent nix-shell which you created for your development server, you may -now start CE with the following command:: - - - pserve --reload configs/dev.ini - -.. note:: - - `--reload` flag will automatically reload the server when source file changes. - - -Run the Environment Tests -^^^^^^^^^^^^^^^^^^^^^^^^^ - -Please make sure that the tests are passing to verify that your environment is -set up correctly. RhodeCode uses py.test to run tests. -While your instance is running, start a new nix-shell and simply run -``make test`` to run the basic test suite. - +Please refer to RCstack installed documentation for instructions on setting up dev environment: +https://docs.rhodecode.com/rcstack/dev/dev-setup.html Need Help? ^^^^^^^^^^ diff --git a/docs/images/saml-azure-attributes-example.png b/docs/images/saml-azure-attributes-example.png new file mode 100644 index 0000000000000000000000000000000000000000..97b50a84d4cf599ec88f6873b0840774056d55ca GIT binary patch literal 26265 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 JsqBlu{{swMss#W5 diff --git a/docs/images/saml-azure-service-provider-example.png b/docs/images/saml-azure-service-provider-example.png new file mode 100644 index 0000000000000000000000000000000000000000..0c2cb0e0c7e70117a3c9ab3271d2b67d5487d807 GIT binary patch literal 87143 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

XK1-lUE{g&+DPBuz~`j1(W$@J3raM? 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

k9TYxSK$x3?mAyVnA_s2Nx8del?~09APTKEF{l zUu{SQGZ)PuU$weh-srZC%!+q+TCrxKbbrCV?AtC7@}gapIjKVd!om!B^CW>)dxs2& zD}#>HV@ua|Xk&nCHpc0GYj09e6$LwLtcRTLoGo9gxN^%cDZ|3E;j>C?pQ3q~4u@N!+Zmuex_mne&@&Hdqbqbt5)OPREb@n0~s&X;+H^Vd`M z0IeQfhM5D09ME@}jU9#OgFMqc6EC7Y5{WWN;{62c>z4bIn|CGX*t5wfc{|?MjhsSP ze(&8hY!Q8(HV|j2lGOv^!)zlkDN{&?oI9M@FD7zxK8ty1J(lU@H zXHarW^#zlTMhQ~`|4NL+W2v<#8ld6P;fqh(aI3 + +.. toctree:: + :maxdepth: 1 + :caption: RhodeCode RCstack Documentation + + RhodeCode RCstack Installer + +.. toctree:: + :maxdepth: 1 :caption: Admin Documentation install/quick-start diff --git a/docs/install/quick-start.rst b/docs/install/quick-start.rst --- a/docs/install/quick-start.rst +++ b/docs/install/quick-start.rst @@ -66,6 +66,7 @@ Output should look similar to this: fb77fb6496c6 channelstream/channelstream:0.7.1 Up 2 hours (healthy) rc_cluster_services-channelstream-1 8000/tcp cb6c5c022f5b postgres:14.6 Up 2 hours (healthy) rc_cluster_services-database-1 5432/tcp + At this point you should be able to access: - RhodeCode instance at your domain entered, e.g http://rhodecode.local, the default access @@ -76,6 +77,7 @@ At this point you should be able to acce RHODECODE_USER_PASS=super-secret-password + .. note:: Recommended post quick start install instructions: @@ -85,7 +87,6 @@ At this point you should be able to acce * Set up :ref:`indexing-ref` * Familiarise yourself with the :ref:`rhodecode-admin-ref` section. -.. _rhodecode.com/download/: https://rhodecode.com/download/ .. _rhodecode.com: https://rhodecode.com/ .. _rhodecode.com/register: https://rhodecode.com/register/ .. _rhodecode.com/download: https://rhodecode.com/download/ diff --git a/docs/install/using-sqllite.rst b/docs/install/using-sqllite.rst --- a/docs/install/using-sqllite.rst +++ b/docs/install/using-sqllite.rst @@ -1,23 +1,12 @@ .. _install-sqlite-database: -SQLite ------- +SQLite (Deprecated) +------------------- .. important:: - We do not recommend using SQLite in a large development environment - as it has an internal locking mechanism which can become a performance - bottleneck when there are more than 5 concurrent users. + As of 5.x, SQLite is no longer supported, we advise to migrate to MySQL or PostgreSQL. -|RCE| installs SQLite as the default database if you do not specify another -during installation. SQLite is suitable for small teams, -projects with a low load, and evaluation purposes since it is built into -|RCE| and does not require any additional database server. - -Using MySQL or PostgreSQL in an large setup gives you much greater -performance, and while migration tools exist to move from one database type -to another, it is better to get it right first time and to immediately use -MySQL or PostgreSQL when you deploy |RCE| in a production environment. Migrating From SQLite to PostgreSQL ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/known-issues/known-issues.rst b/docs/known-issues/known-issues.rst --- a/docs/known-issues/known-issues.rst +++ b/docs/known-issues/known-issues.rst @@ -42,6 +42,7 @@ Newer Operating system locales the local-archive format, which is now incompatible with our used glibc 2.26. Mostly affected are: + - Fedora 23+ - Ubuntu 18.04 - CentOS / RHEL 8 @@ -93,3 +94,24 @@ example to pass the correct locale infor [Install] WantedBy=multi-user.target + +Merge stucks in "merging" status +-------------------------------- + +Similar issues: + +- Pull Request duplicated and/or stucks in "creating" status. + +Mostly affected are: + +- Kubernetes AWS EKS setup with NFS as shared storage +- AWS EFS as shared storage + +Workaround: + +1. Manually clear the repo cache via UI: +:menuselection:`Repository Settings --> Caches --> Invalidate repository cache` + +1. Open problematic PR and reset status to "created" + +Now you can merge PR normally diff --git a/docs/release-notes/release-notes-5.1.0.rst b/docs/release-notes/release-notes-5.1.0.rst --- a/docs/release-notes/release-notes-5.1.0.rst +++ b/docs/release-notes/release-notes-5.1.0.rst @@ -10,20 +10,20 @@ Release Date New Features ^^^^^^^^^^^^ -- We've introduced 2FA for users. Now alongside the external auth 2fa support RhodeCode allows to enable 2FA for users +- We've introduced 2FA for users. Now alongside the external auth 2FA support RhodeCode allows to enable 2FA for users. 2FA options will be available for each user individually, or enforced via authentication plugins like ldap, or internal. - Email based log-in. RhodeCode now allows to log-in using email as well as username for main authentication type. - Ability to replace a file using web UI. Now one can replace an existing file from the web-ui. - GIT LFS Sync automation. Remote push/pull commands now can also sync GIT LFS objects. -- Added ability to remove or close branches from the web ui -- Added ability to delete a branch automatically after merging PR for git repositories -- Added support for S3 based archive_cache based that allows storing cached archives in S3 compatible object store. +- Added ability to remove or close branches from the web ui. +- Added ability to delete a branch automatically after merging PR for git repositories. +- Added support for S3 based archive_cache that allows storing cached archives in S3 compatible object store. General ^^^^^^^ -- Upgraded all dependency libraries to their latest available versions +- Upgraded all dependency libraries to their latest available versions. - Repository storage is no longer controlled via DB settings, but .ini file. This allows easier automated deployments. - Bumped mercurial to 6.7.4 - Mercurial: enable httppostarguments for better support of large repositories with lots of heads. @@ -39,21 +39,20 @@ Performance ^^^^^^^^^^^ - Introduced a full rewrite of ssh backend for performance. The result is 2-5x speed improvement for operation with ssh. - enable new ssh wrapper by setting: `ssh.wrapper_cmd = /home/rhodecode/venv/bin/rc-ssh-wrapper-v2` -- Introduced a new hooks subsystem that is more scalable and faster, enable it by settings: `vcs.hooks.protocol = celery` + Enable new ssh wrapper by setting: `ssh.wrapper_cmd = /home/rhodecode/venv/bin/rc-ssh-wrapper-v2` +- Introduced a new hooks subsystem that is more scalable and faster, enable it by setting: `vcs.hooks.protocol = celery` Fixes ^^^^^ -- Archives: Zip archive download breaks when a gitmodules file is present -- Branch permissions: fixed bug preventing to specify own rules from 4.X install -- SVN: refactored svn events, thus fixing support for it in dockerized env -- Fixed empty server url in PR link after push from cli +- Archives: Zip archive download breaks when a gitmodules file is present. +- Branch permissions: fixed bug preventing to specify own rules from 4.X install. +- SVN: refactored svn events, thus fixing support for it in dockerized environment. +- Fixed empty server url in PR link after push from cli. Upgrade notes ^^^^^^^^^^^^^ -- RhodeCode 5.1.0 is a mayor feature release after big 5.0.0 python3 migration. Happy to ship a first time feature - rich release +- RhodeCode 5.1.0 is a major feature release after big 5.0.0 python3 migration. Happy to ship a first time feature-rich release. diff --git a/docs/release-notes/release-notes-5.1.1.rst b/docs/release-notes/release-notes-5.1.1.rst new file mode 100644 --- /dev/null +++ b/docs/release-notes/release-notes-5.1.1.rst @@ -0,0 +1,40 @@ +|RCE| 5.1.1 |RNS| +----------------- + +Release Date +^^^^^^^^^^^^ + +- 2024-07-23 + + +New Features +^^^^^^^^^^^^ + + + +General +^^^^^^^ + + + +Security +^^^^^^^^ + + + +Performance +^^^^^^^^^^^ + + + + +Fixes +^^^^^ + +- Fixed problems with JS static files build + + +Upgrade notes +^^^^^^^^^^^^^ + +- RhodeCode 5.1.1 is unscheduled bugfix release to address some build issues with 5.1 images diff --git a/docs/release-notes/release-notes-5.1.2.rst b/docs/release-notes/release-notes-5.1.2.rst new file mode 100644 --- /dev/null +++ b/docs/release-notes/release-notes-5.1.2.rst @@ -0,0 +1,41 @@ +|RCE| 5.1.2 |RNS| +----------------- + +Release Date +^^^^^^^^^^^^ + +- 2024-09-12 + + +New Features +^^^^^^^^^^^^ + + + +General +^^^^^^^ + + + +Security +^^^^^^^^ + + + +Performance +^^^^^^^^^^^ + + + + +Fixes +^^^^^ + +- Fixed problems with Mercurial authentication after enabling httppostargs. + Currently this protocol will be disabled until proper fix is in place + + +Upgrade notes +^^^^^^^^^^^^^ + +- RhodeCode 5.1.2 is unscheduled bugfix release to address some build issues with 5.1 images diff --git a/docs/release-notes/release-notes-5.2.0.rst b/docs/release-notes/release-notes-5.2.0.rst new file mode 100644 --- /dev/null +++ b/docs/release-notes/release-notes-5.2.0.rst @@ -0,0 +1,55 @@ +|RCE| 5.2.0 |RNS| +----------------- + +Release Date +^^^^^^^^^^^^ + +- 2024-10-09 + + +New Features +^^^^^^^^^^^^ + +- New artifact storage engines allowing an s3 based uploads +- Enterprise version only: Added security tab to admin interface and possibility to whitelist specific vcs client versions. Some older versions clients have known security vulnerabilities, now you can disallow them. +- Enterprise version only: Implemented support for Azure SAML authentication + + +General +^^^^^^^ +- Bumped version of packaging, gunicorn, orjson, zope.interface and some other requirements +- Few tweaks and changes to saml plugins to allows easier setup +- Configs: allow json log format for gunicorn +- Configs: deprecated old ssh wrapper command and make the v2 the default one +- Make sure commit-caches propagate to parent repo groups +- Configs: Moved git lfs path and path of hg large files to ini file + +Security +^^^^^^^^ + + + +Performance +^^^^^^^^^^^ + +- description escaper for better performance + +Fixes +^^^^^ + +- Email notifications not working properly +- Removed waitress as a default runner +- Fixed issue with branch permissions +- Ldap: fixed nested groups extraction logic +- Fixed possible db corruption in case of filesystem problems +- Cleanup and improvements to documentation +- Added Kubernetes deployment section to the documentation +- Added default value to celery result and broker +- Fixed broken backends function after python3 migration +- Explicitly disable mercurial web_push ssl flag to prevent from errors about ssl required +- VCS: fixed problems with locked repos and with branch permissions reporting + +Upgrade notes +^^^^^^^^^^^^^ + +- RhodeCode 5.2.0 is a planned major release featuring Azure SAML, whitelist for client versions, s3 artifacts backend and more! diff --git a/docs/release-notes/release-notes.rst b/docs/release-notes/release-notes.rst --- a/docs/release-notes/release-notes.rst +++ b/docs/release-notes/release-notes.rst @@ -9,7 +9,9 @@ Release Notes .. toctree:: :maxdepth: 1 - + release-notes-5.2.0.rst + release-notes-5.1.2.rst + release-notes-5.1.1.rst release-notes-5.1.0.rst release-notes-5.0.3.rst release-notes-5.0.2.rst diff --git a/docs/requirements_docs.txt b/docs/requirements_docs.txt --- a/docs/requirements_docs.txt +++ b/docs/requirements_docs.txt @@ -4,7 +4,7 @@ furo==2023.9.10 sphinx-press-theme==0.8.0 sphinx-rtd-theme==1.3.0 -pygments==2.16.1 +pygments==2.18.0 docutils<0.19 markupsafe==2.1.3 diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ alembic==1.13.1 markupsafe==2.1.2 sqlalchemy==1.4.52 greenlet==3.0.3 - typing_extensions==4.9.0 + typing_extensions==4.12.2 async-timeout==4.0.3 babel==2.12.1 beaker==1.12.1 @@ -18,8 +18,8 @@ celery==5.3.6 click==8.1.3 click-repl==0.2.0 click==8.1.3 - prompt-toolkit==3.0.38 - wcwidth==0.2.6 + prompt_toolkit==3.0.47 + wcwidth==0.2.13 six==1.16.0 kombu==5.3.5 amqp==5.2.0 @@ -33,7 +33,7 @@ channelstream==0.7.1 gevent==24.2.1 greenlet==3.0.3 zope.event==5.0.0 - zope.interface==6.3.0 + zope.interface==7.0.3 itsdangerous==1.1.0 marshmallow==2.18.0 pyramid==2.0.2 @@ -46,7 +46,7 @@ channelstream==0.7.1 venusian==3.0.0 webob==1.8.7 zope.deprecation==5.0.0 - zope.interface==6.3.0 + zope.interface==7.0.3 pyramid-jinja2==2.10 jinja2==3.1.2 markupsafe==2.1.2 @@ -61,7 +61,7 @@ channelstream==0.7.1 venusian==3.0.0 webob==1.8.7 zope.deprecation==5.0.0 - zope.interface==6.3.0 + zope.interface==7.0.3 zope.deprecation==5.0.0 python-dateutil==2.8.2 six==1.16.0 @@ -87,32 +87,31 @@ dogpile.cache==1.3.3 pbr==5.11.1 formencode==2.1.0 six==1.16.0 -fsspec==2024.6.0 -gunicorn==21.2.0 - packaging==24.0 +fsspec==2024.9.0 +gunicorn==23.0.0 + packaging==24.1 gevent==24.2.1 greenlet==3.0.3 zope.event==5.0.0 - zope.interface==6.3.0 -ipython==8.14.0 - backcall==0.2.0 + zope.interface==7.0.3 +ipython==8.26.0 decorator==5.1.1 - jedi==0.19.0 - parso==0.8.3 - matplotlib-inline==0.1.6 - traitlets==5.9.0 - pexpect==4.8.0 + jedi==0.19.1 + parso==0.8.4 + matplotlib-inline==0.1.7 + traitlets==5.14.3 + pexpect==4.9.0 ptyprocess==0.7.0 - pickleshare==0.7.5 - prompt-toolkit==3.0.38 - wcwidth==0.2.6 - pygments==2.15.1 - stack-data==0.6.2 - asttokens==2.2.1 + prompt_toolkit==3.0.47 + wcwidth==0.2.13 + pygments==2.18.0 + stack-data==0.6.3 + asttokens==2.4.1 six==1.16.0 - executing==1.2.0 - pure-eval==0.2.2 - traitlets==5.9.0 + executing==2.0.1 + pure_eval==0.2.3 + traitlets==5.14.3 + typing_extensions==4.12.2 markdown==3.4.3 msgpack==1.0.8 mysqlclient==2.1.1 @@ -127,7 +126,7 @@ nbconvert==7.7.3 markupsafe==2.1.2 jupyter_core==5.3.1 platformdirs==3.10.0 - traitlets==5.9.0 + traitlets==5.14.3 jupyterlab-pygments==0.2.2 markupsafe==2.1.2 mistune==2.0.5 @@ -135,15 +134,15 @@ nbconvert==7.7.3 jupyter_client==8.3.0 jupyter_core==5.3.1 platformdirs==3.10.0 - traitlets==5.9.0 + traitlets==5.14.3 python-dateutil==2.8.2 six==1.16.0 pyzmq==25.0.0 tornado==6.2 - traitlets==5.9.0 + traitlets==5.14.3 jupyter_core==5.3.1 platformdirs==3.10.0 - traitlets==5.9.0 + traitlets==5.14.3 nbformat==5.9.2 fastjsonschema==2.18.0 jsonschema==4.18.6 @@ -151,9 +150,9 @@ nbconvert==7.7.3 pyrsistent==0.19.3 jupyter_core==5.3.1 platformdirs==3.10.0 - traitlets==5.9.0 - traitlets==5.9.0 - traitlets==5.9.0 + traitlets==5.14.3 + traitlets==5.14.3 + traitlets==5.14.3 nbformat==5.9.2 fastjsonschema==2.18.0 jsonschema==4.18.6 @@ -161,20 +160,20 @@ nbconvert==7.7.3 pyrsistent==0.19.3 jupyter_core==5.3.1 platformdirs==3.10.0 - traitlets==5.9.0 - traitlets==5.9.0 + traitlets==5.14.3 + traitlets==5.14.3 pandocfilters==1.5.0 - pygments==2.15.1 + pygments==2.18.0 tinycss2==1.2.1 webencodings==0.5.1 - traitlets==5.9.0 -orjson==3.10.3 + traitlets==5.14.3 +orjson==3.10.7 paste==3.10.1 premailer==3.10.0 cachetools==5.3.3 cssselect==1.2.0 cssutils==2.6.0 - lxml==4.9.3 + lxml==5.3.0 requests==2.28.2 certifi==2022.12.7 charset-normalizer==3.1.0 @@ -191,33 +190,6 @@ pycurl==7.45.3 pymysql==1.0.3 pyotp==2.8.0 pyparsing==3.1.1 -pyramid-debugtoolbar==4.12.1 - pygments==2.15.1 - pyramid==2.0.2 - hupper==1.12 - plaster==1.1.2 - plaster-pastedeploy==1.0.1 - pastedeploy==3.1.0 - plaster==1.1.2 - translationstring==1.4 - venusian==3.0.0 - webob==1.8.7 - zope.deprecation==5.0.0 - zope.interface==6.3.0 - pyramid-mako==1.1.0 - mako==1.2.4 - markupsafe==2.1.2 - pyramid==2.0.2 - hupper==1.12 - plaster==1.1.2 - plaster-pastedeploy==1.0.1 - pastedeploy==3.1.0 - plaster==1.1.2 - translationstring==1.4 - venusian==3.0.0 - webob==1.8.7 - zope.deprecation==5.0.0 - zope.interface==6.3.0 pyramid-mailer==0.15.1 pyramid==2.0.2 hupper==1.12 @@ -229,13 +201,27 @@ pyramid-mailer==0.15.1 venusian==3.0.0 webob==1.8.7 zope.deprecation==5.0.0 - zope.interface==6.3.0 + zope.interface==7.0.3 repoze.sendmail==4.4.1 - transaction==3.1.0 - zope.interface==6.3.0 - zope.interface==6.3.0 - transaction==3.1.0 - zope.interface==6.3.0 + transaction==5.0.0 + zope.interface==7.0.3 + zope.interface==7.0.3 + transaction==5.0.0 + zope.interface==7.0.3 +pyramid-mako==1.1.0 + mako==1.2.4 + markupsafe==2.1.2 + pyramid==2.0.2 + hupper==1.12 + plaster==1.1.2 + plaster-pastedeploy==1.0.1 + pastedeploy==3.1.0 + plaster==1.1.2 + translationstring==1.4 + venusian==3.0.0 + webob==1.8.7 + zope.deprecation==5.0.0 + zope.interface==7.0.3 python-ldap==3.4.3 pyasn1==0.4.8 pyasn1-modules==0.2.8 @@ -243,20 +229,20 @@ python-ldap==3.4.3 python-memcached==1.59 six==1.16.0 python-pam==2.0.2 -python3-saml==1.15.0 +python3-saml==1.16.0 isodate==0.6.1 six==1.16.0 - lxml==4.9.3 - xmlsec==1.3.13 - lxml==4.9.3 + lxml==5.3.0 + xmlsec==1.3.14 + lxml==5.3.0 pyyaml==6.0.1 -redis==5.0.4 +redis==5.1.0 async-timeout==4.0.3 regex==2022.10.31 routes==2.5.1 repoze.lru==0.7 six==1.16.0 -s3fs==2024.6.0 +s3fs==2024.9.0 aiobotocore==2.13.0 aiohttp==3.9.5 aiosignal==1.3.1 @@ -283,7 +269,7 @@ s3fs==2024.6.0 yarl==1.9.4 idna==3.4 multidict==6.0.5 - fsspec==2024.6.0 + fsspec==2024.9.0 simplejson==3.19.2 sshpubkeys==3.3.1 cryptography==40.0.2 @@ -293,7 +279,7 @@ sshpubkeys==3.3.1 six==1.16.0 sqlalchemy==1.4.52 greenlet==3.0.3 - typing_extensions==4.9.0 + typing_extensions==4.12.2 supervisor==4.2.5 tzlocal==4.3 pytz-deprecation-shim==0.1.0.post0 diff --git a/requirements_debug.txt b/requirements_debug.txt --- a/requirements_debug.txt +++ b/requirements_debug.txt @@ -9,6 +9,7 @@ pympler ipdb ipython rich +pyramid-debugtoolbar # format flake8 diff --git a/requirements_test.txt b/requirements_test.txt --- a/requirements_test.txt +++ b/requirements_test.txt @@ -4,38 +4,38 @@ pytest-cov==4.1.0 coverage==7.4.3 pytest==8.1.1 iniconfig==2.0.0 - packaging==24.0 + packaging==24.1 pluggy==1.4.0 pytest-env==1.1.3 pytest==8.1.1 iniconfig==2.0.0 - packaging==24.0 + packaging==24.1 pluggy==1.4.0 pytest-profiling==1.7.0 gprof2dot==2022.7.29 pytest==8.1.1 iniconfig==2.0.0 - packaging==24.0 + packaging==24.1 pluggy==1.4.0 six==1.16.0 pytest-rerunfailures==13.0 - packaging==24.0 + packaging==24.1 pytest==8.1.1 iniconfig==2.0.0 - packaging==24.0 + packaging==24.1 pluggy==1.4.0 pytest-runner==6.0.1 pytest-sugar==1.0.0 - packaging==24.0 + packaging==24.1 pytest==8.1.1 iniconfig==2.0.0 - packaging==24.0 + packaging==24.1 pluggy==1.4.0 termcolor==2.4.0 pytest-timeout==2.3.1 pytest==8.1.1 iniconfig==2.0.0 - packaging==24.0 + packaging==24.1 pluggy==1.4.0 webtest==3.0.0 beautifulsoup4==4.12.3 diff --git a/rhodecode/VERSION b/rhodecode/VERSION --- a/rhodecode/VERSION +++ b/rhodecode/VERSION @@ -1,1 +1,1 @@ -5.1.2 \ No newline at end of file +5.2.0 diff --git a/rhodecode/api/__init__.py b/rhodecode/api/__init__.py --- a/rhodecode/api/__init__.py +++ b/rhodecode/api/__init__.py @@ -40,6 +40,7 @@ from rhodecode.lib import ext_json from rhodecode.lib.utils2 import safe_str from rhodecode.lib.plugins.utils import get_plugin_settings from rhodecode.model.db import User, UserApiKeys +from rhodecode.config.patches import inspect_getargspec log = logging.getLogger(__name__) @@ -186,7 +187,6 @@ def request_view(request): exposed method """ # cython compatible inspect - from rhodecode.config.patches import inspect_getargspec inspect = inspect_getargspec() # check if we can find this session using api_key, get_by_auth_token diff --git a/rhodecode/api/views/server_api.py b/rhodecode/api/views/server_api.py --- a/rhodecode/api/views/server_api.py +++ b/rhodecode/api/views/server_api.py @@ -33,7 +33,7 @@ from rhodecode.lib.ext_json import json from rhodecode.lib.utils2 import safe_int from rhodecode.model.db import UserIpMap from rhodecode.model.scm import ScmModel -from rhodecode.apps.file_store import utils +from rhodecode.apps.file_store import utils as store_utils from rhodecode.apps.file_store.exceptions import FileNotAllowedException, \ FileOverSizeException @@ -328,8 +328,8 @@ def get_method(request, apiuser, pattern ] error : null """ - from rhodecode.config.patches import inspect_getargspec - inspect = inspect_getargspec() + from rhodecode.config import patches + inspect = patches.inspect_getargspec() if not has_superadmin_permission(apiuser): raise JSONRPCForbidden() diff --git a/rhodecode/apps/admin/__init__.py b/rhodecode/apps/admin/__init__.py --- a/rhodecode/apps/admin/__init__.py +++ b/rhodecode/apps/admin/__init__.py @@ -43,7 +43,38 @@ def admin_routes(config): from rhodecode.apps.admin.views.system_info import AdminSystemInfoSettingsView from rhodecode.apps.admin.views.user_groups import AdminUserGroupsView from rhodecode.apps.admin.views.users import AdminUsersView, UsersView - + from rhodecode.apps.admin.views.security import AdminSecurityView + + # Security EE feature + + config.add_route( + 'admin_security', + pattern='/security') + config.add_view( + AdminSecurityView, + attr='security', + route_name='admin_security', request_method='GET', + renderer='rhodecode:templates/admin/security/security.mako') + + config.add_route( + name='admin_security_update', + pattern='/security/update') + config.add_view( + AdminSecurityView, + attr='security_update', + route_name='admin_security_update', request_method='POST', + renderer='rhodecode:templates/admin/security/security.mako') + + config.add_route( + name='admin_security_modify_allowed_vcs_client_versions', + pattern=ADMIN_PREFIX + '/security/modify/allowed_vcs_client_versions') + config.add_view( + AdminSecurityView, + attr='vcs_whitelisted_client_versions_edit', + route_name='admin_security_modify_allowed_vcs_client_versions', request_method=('GET', 'POST'), + renderer='rhodecode:templates/admin/security/edit_allowed_vcs_client_versions.mako') + + config.add_route( name='admin_audit_logs', pattern='/audit_logs') diff --git a/rhodecode/apps/admin/views/security.py b/rhodecode/apps/admin/views/security.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/admin/views/security.py @@ -0,0 +1,46 @@ +# Copyright (C) 2010-2024 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import logging + +from rhodecode.apps._base import BaseAppView +from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator + +log = logging.getLogger(__name__) + + +class AdminSecurityView(BaseAppView): + + def load_default_context(self): + c = self._get_local_tmpl_context() + return c + + @LoginRequired() + @HasPermissionAllDecorator('hg.admin') + def security(self): + c = self.load_default_context() + c.active = 'security' + return self._get_template_context(c) + + + @LoginRequired() + @HasPermissionAllDecorator('hg.admin') + def admin_security_modify_allowed_vcs_client_versions(self): + c = self.load_default_context() + c.active = 'security' + return self._get_template_context(c) diff --git a/rhodecode/apps/admin/views/settings.py b/rhodecode/apps/admin/views/settings.py --- a/rhodecode/apps/admin/views/settings.py +++ b/rhodecode/apps/admin/views/settings.py @@ -75,14 +75,21 @@ class AdminSettingsView(BaseAppView): if not ret: raise Exception('Could not get application ui settings !') - settings = {} + settings = { + # legacy param that needs to be kept + 'web_push_ssl': False + } for each in ret: k = each.ui_key v = each.ui_value + # skip some options if they are defined + if k in ['push_ssl']: + continue + if k == '/': k = 'root_path' - if k in ['push_ssl', 'publish', 'enabled']: + if k in ['publish', 'enabled']: v = str2bool(v) if k.find('.') != -1: @@ -92,6 +99,7 @@ class AdminSettingsView(BaseAppView): v = each.ui_active settings[each.ui_section + '_' + k] = v + return settings @classmethod @@ -164,7 +172,6 @@ class AdminSettingsView(BaseAppView): return Response(html) try: - model.update_global_ssl_setting(form_result['web_push_ssl']) model.update_global_hook_settings(form_result) model.create_or_update_global_svn_settings(form_result) diff --git a/rhodecode/apps/admin/views/system_info.py b/rhodecode/apps/admin/views/system_info.py --- a/rhodecode/apps/admin/views/system_info.py +++ b/rhodecode/apps/admin/views/system_info.py @@ -171,11 +171,17 @@ class AdminSystemInfoSettingsView(BaseAp (_('Gist storage info'), val('storage_gist')['text'], state('storage_gist')), ('', '', ''), # spacer - (_('Archive cache storage type'), val('storage_archive')['type'], state('storage_archive')), + (_('Artifacts storage backend'), val('storage_artifacts')['type'], state('storage_artifacts')), + (_('Artifacts storage location'), val('storage_artifacts')['path'], state('storage_artifacts')), + (_('Artifacts info'), val('storage_artifacts')['text'], state('storage_artifacts')), + ('', '', ''), # spacer + + (_('Archive cache storage backend'), val('storage_archive')['type'], state('storage_archive')), (_('Archive cache storage location'), val('storage_archive')['path'], state('storage_archive')), (_('Archive cache info'), val('storage_archive')['text'], state('storage_archive')), ('', '', ''), # spacer + (_('Temp storage location'), val('storage_temp')['path'], state('storage_temp')), (_('Temp storage info'), val('storage_temp')['text'], state('storage_temp')), ('', '', ''), # spacer diff --git a/rhodecode/apps/file_store/__init__.py b/rhodecode/apps/file_store/__init__.py --- a/rhodecode/apps/file_store/__init__.py +++ b/rhodecode/apps/file_store/__init__.py @@ -16,7 +16,8 @@ # RhodeCode Enterprise Edition, including its added features, Support services, # and proprietary license terms, please see https://rhodecode.com/licenses/ import os -from rhodecode.apps.file_store import config_keys + + from rhodecode.config.settings_maker import SettingsMaker @@ -24,18 +25,48 @@ def _sanitize_settings_and_apply_default """ Set defaults, convert to python types and validate settings. """ + from rhodecode.apps.file_store import config_keys + + # translate "legacy" params into new config + settings.pop(config_keys.deprecated_enabled, True) + if config_keys.deprecated_backend in settings: + # if legacy backend key is detected we use "legacy" backward compat setting + settings.pop(config_keys.deprecated_backend) + settings[config_keys.backend_type] = config_keys.backend_legacy_filesystem + + if config_keys.deprecated_store_path in settings: + store_path = settings.pop(config_keys.deprecated_store_path) + settings[config_keys.legacy_filesystem_storage_path] = store_path + settings_maker = SettingsMaker(settings) - settings_maker.make_setting(config_keys.enabled, True, parser='bool') - settings_maker.make_setting(config_keys.backend, 'local') + default_cache_dir = settings['cache_dir'] + default_store_dir = os.path.join(default_cache_dir, 'artifacts_filestore') + + # set default backend + settings_maker.make_setting(config_keys.backend_type, config_keys.backend_legacy_filesystem) + + # legacy filesystem defaults + settings_maker.make_setting(config_keys.legacy_filesystem_storage_path, default_store_dir, default_when_empty=True, ) - default_store = os.path.join(os.path.dirname(settings['__file__']), 'upload_store') - settings_maker.make_setting(config_keys.store_path, default_store) + # filesystem defaults + settings_maker.make_setting(config_keys.filesystem_storage_path, default_store_dir, default_when_empty=True,) + settings_maker.make_setting(config_keys.filesystem_shards, 8, parser='int') + + # objectstore defaults + settings_maker.make_setting(config_keys.objectstore_url, 'http://s3-minio:9000') + settings_maker.make_setting(config_keys.objectstore_bucket, 'rhodecode-artifacts-filestore') + settings_maker.make_setting(config_keys.objectstore_bucket_shards, 8, parser='int') + + settings_maker.make_setting(config_keys.objectstore_region, '') + settings_maker.make_setting(config_keys.objectstore_key, '') + settings_maker.make_setting(config_keys.objectstore_secret, '') settings_maker.env_expand() def includeme(config): + from rhodecode.apps.file_store.views import FileStoreView settings = config.registry.settings diff --git a/rhodecode/apps/file_store/backends/base.py b/rhodecode/apps/file_store/backends/base.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/file_store/backends/base.py @@ -0,0 +1,269 @@ +# Copyright (C) 2016-2023 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import os +import fsspec # noqa +import logging + +from rhodecode.lib.ext_json import json + +from rhodecode.apps.file_store.utils import sha256_safe, ShardFileReader, get_uid_filename +from rhodecode.apps.file_store.extensions import resolve_extensions +from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException # noqa: F401 + +log = logging.getLogger(__name__) + + +class BaseShard: + + metadata_suffix: str = '.metadata' + storage_type: str = '' + fs = None + + @property + def storage_medium(self): + if not self.storage_type: + raise ValueError('No storage type set for this shard storage_type=""') + return getattr(self, self.storage_type) + + def __contains__(self, key): + full_path = self.store_path(key) + return self.fs.exists(full_path) + + def metadata_convert(self, uid_filename, metadata): + return metadata + + def get_metadata_filename(self, uid_filename) -> tuple[str, str]: + metadata_file: str = f'{uid_filename}{self.metadata_suffix}' + return metadata_file, self.store_path(metadata_file) + + def get_metadata(self, uid_filename, ignore_missing=False) -> dict: + _metadata_file, metadata_file_path = self.get_metadata_filename(uid_filename) + if ignore_missing and not self.fs.exists(metadata_file_path): + return {} + + with self.fs.open(metadata_file_path, 'rb') as f: + metadata = json.loads(f.read()) + + metadata = self.metadata_convert(uid_filename, metadata) + return metadata + + def _store(self, key: str, uid_key: str, value_reader, max_filesize: int | None = None, metadata: dict | None = None, **kwargs): + raise NotImplementedError + + def store(self, key: str, uid_key: str, value_reader, max_filesize: int | None = None, metadata: dict | None = None, **kwargs): + return self._store(key, uid_key, value_reader, max_filesize, metadata, **kwargs) + + def _fetch(self, key, presigned_url_expires: int = 0): + raise NotImplementedError + + def fetch(self, key, **kwargs) -> tuple[ShardFileReader, dict]: + return self._fetch(key) + + def _delete(self, key): + if key not in self: + log.exception(f'requested key={key} not found in {self}') + raise KeyError(key) + + metadata = self.get_metadata(key) + _metadata_file, metadata_file_path = self.get_metadata_filename(key) + artifact_file_path = metadata['filename_uid_path'] + self.fs.rm(artifact_file_path) + self.fs.rm(metadata_file_path) + + return 1 + + def delete(self, key): + raise NotImplementedError + + def store_path(self, uid_filename): + raise NotImplementedError + + +class BaseFileStoreBackend: + _shards = tuple() + _shard_cls = BaseShard + _config: dict | None = None + _storage_path: str = '' + + def __init__(self, settings, extension_groups=None): + self._config = settings + extension_groups = extension_groups or ['any'] + self.extensions = resolve_extensions([], groups=extension_groups) + + def __contains__(self, key): + return self.filename_exists(key) + + def __repr__(self): + return f'<{self.__class__.__name__}(storage={self.storage_path})>' + + @property + def storage_path(self): + return self._storage_path + + @classmethod + def get_shard_index(cls, filename: str, num_shards) -> int: + # Generate a hash value from the filename + hash_value = sha256_safe(filename) + + # Convert the hash value to an integer + hash_int = int(hash_value, 16) + + # Map the hash integer to a shard number between 1 and num_shards + shard_number = (hash_int % num_shards) + + return shard_number + + @classmethod + def apply_counter(cls, counter: int, filename: str) -> str: + """ + Apply a counter to the filename. + + :param counter: The counter value to apply. + :param filename: The original filename. + :return: The modified filename with the counter. + """ + name_counted = f'{counter:d}-{filename}' + return name_counted + + def _get_shard(self, key) -> _shard_cls: + index = self.get_shard_index(key, len(self._shards)) + shard = self._shards[index] + return shard + + def get_conf(self, key, pop=False): + if key not in self._config: + raise ValueError( + f"No configuration key '{key}', please make sure it exists in filestore config") + val = self._config[key] + if pop: + del self._config[key] + return val + + def filename_allowed(self, filename, extensions=None): + """Checks if a filename has an allowed extension + + :param filename: base name of file + :param extensions: iterable of extensions (or self.extensions) + """ + _, ext = os.path.splitext(filename) + return self.extension_allowed(ext, extensions) + + def extension_allowed(self, ext, extensions=None): + """ + Checks if an extension is permitted. Both e.g. ".jpg" and + "jpg" can be passed in. Extension lookup is case-insensitive. + + :param ext: extension to check + :param extensions: iterable of extensions to validate against (or self.extensions) + """ + def normalize_ext(_ext): + if _ext.startswith('.'): + _ext = _ext[1:] + return _ext.lower() + + extensions = extensions or self.extensions + if not extensions: + return True + + ext = normalize_ext(ext) + + return ext in [normalize_ext(x) for x in extensions] + + def filename_exists(self, uid_filename): + shard = self._get_shard(uid_filename) + return uid_filename in shard + + def store_path(self, uid_filename): + """ + Returns absolute file path of the uid_filename + """ + shard = self._get_shard(uid_filename) + return shard.store_path(uid_filename) + + def store_metadata(self, uid_filename): + shard = self._get_shard(uid_filename) + return shard.get_metadata_filename(uid_filename) + + def store(self, filename, value_reader, extensions=None, metadata=None, max_filesize=None, randomized_name=True, **kwargs): + extensions = extensions or self.extensions + + if not self.filename_allowed(filename, extensions): + msg = f'filename {filename} does not allow extensions {extensions}' + raise FileNotAllowedException(msg) + + # # TODO: check why we need this setting ? it looks stupid... + # no_body_seek is used in stream mode importer somehow + # no_body_seek = kwargs.pop('no_body_seek', False) + # if no_body_seek: + # pass + # else: + # value_reader.seek(0) + + uid_filename = kwargs.pop('uid_filename', None) + if uid_filename is None: + uid_filename = get_uid_filename(filename, randomized=randomized_name) + + shard = self._get_shard(uid_filename) + + return shard.store(filename, uid_filename, value_reader, max_filesize, metadata, **kwargs) + + def import_to_store(self, value_reader, org_filename, uid_filename, metadata, **kwargs): + shard = self._get_shard(uid_filename) + max_filesize = None + return shard.store(org_filename, uid_filename, value_reader, max_filesize, metadata, import_mode=True) + + def delete(self, uid_filename): + shard = self._get_shard(uid_filename) + return shard.delete(uid_filename) + + def fetch(self, uid_filename) -> tuple[ShardFileReader, dict]: + shard = self._get_shard(uid_filename) + return shard.fetch(uid_filename) + + def get_metadata(self, uid_filename, ignore_missing=False) -> dict: + shard = self._get_shard(uid_filename) + return shard.get_metadata(uid_filename, ignore_missing=ignore_missing) + + def iter_keys(self): + for shard in self._shards: + if shard.fs.exists(shard.storage_medium): + for path, _dirs, _files in shard.fs.walk(shard.storage_medium): + for key_file_path in _files: + if key_file_path.endswith(shard.metadata_suffix): + yield shard, key_file_path + + def iter_artifacts(self): + for shard, key_file in self.iter_keys(): + json_key = f"{shard.storage_medium}/{key_file}" + with shard.fs.open(json_key, 'rb') as f: + yield shard, json.loads(f.read())['filename_uid'] + + def get_statistics(self): + total_files = 0 + total_size = 0 + meta = {} + + for shard, key_file in self.iter_keys(): + json_key = f"{shard.storage_medium}/{key_file}" + with shard.fs.open(json_key, 'rb') as f: + total_files += 1 + metadata = json.loads(f.read()) + total_size += metadata['size'] + + return total_files, total_size, meta diff --git a/rhodecode/apps/file_store/backends/filesystem.py b/rhodecode/apps/file_store/backends/filesystem.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/file_store/backends/filesystem.py @@ -0,0 +1,183 @@ +# Copyright (C) 2016-2023 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import os +import hashlib +import functools +import time +import logging + +from .. import config_keys +from ..exceptions import FileOverSizeException +from ..backends.base import BaseFileStoreBackend, fsspec, BaseShard, ShardFileReader + +from ....lib.ext_json import json + +log = logging.getLogger(__name__) + + +class FileSystemShard(BaseShard): + METADATA_VER = 'v2' + BACKEND_TYPE = config_keys.backend_filesystem + storage_type: str = 'directory' + + def __init__(self, index, directory, directory_folder, fs, **settings): + self._index: int = index + self._directory: str = directory + self._directory_folder: str = directory_folder + self.fs = fs + + @property + def directory(self) -> str: + """Cache directory final path.""" + return os.path.join(self._directory, self._directory_folder) + + def _write_file(self, full_path, iterator, max_filesize, mode='wb'): + + # ensure dir exists + destination, _ = os.path.split(full_path) + if not self.fs.exists(destination): + self.fs.makedirs(destination) + + writer = self.fs.open(full_path, mode) + + digest = hashlib.sha256() + oversize_cleanup = False + with writer: + size = 0 + for chunk in iterator: + size += len(chunk) + digest.update(chunk) + writer.write(chunk) + + if max_filesize and size > max_filesize: + oversize_cleanup = True + # free up the copied file, and raise exc + break + + writer.flush() + # Get the file descriptor + fd = writer.fileno() + + # Sync the file descriptor to disk, helps with NFS cases... + os.fsync(fd) + + if oversize_cleanup: + self.fs.rm(full_path) + raise FileOverSizeException(f'given file is over size limit ({max_filesize}): {full_path}') + + sha256 = digest.hexdigest() + log.debug('written new artifact under %s, sha256: %s', full_path, sha256) + return size, sha256 + + def _store(self, key: str, uid_key, value_reader, max_filesize: int | None = None, metadata: dict | None = None, **kwargs): + + filename = key + uid_filename = uid_key + full_path = self.store_path(uid_filename) + + # STORE METADATA + _metadata = { + "version": self.METADATA_VER, + "store_type": self.BACKEND_TYPE, + + "filename": filename, + "filename_uid_path": full_path, + "filename_uid": uid_filename, + "sha256": "", # NOTE: filled in by reader iteration + + "store_time": time.time(), + + "size": 0 + } + + if metadata: + if kwargs.pop('import_mode', False): + # in import mode, we don't need to compute metadata, we just take the old version + _metadata["import_mode"] = True + else: + _metadata.update(metadata) + + read_iterator = iter(functools.partial(value_reader.read, 2**22), b'') + size, sha256 = self._write_file(full_path, read_iterator, max_filesize) + _metadata['size'] = size + _metadata['sha256'] = sha256 + + # after storing the artifacts, we write the metadata present + _metadata_file, metadata_file_path = self.get_metadata_filename(uid_key) + + with self.fs.open(metadata_file_path, 'wb') as f: + f.write(json.dumps(_metadata)) + + return uid_filename, _metadata + + def store_path(self, uid_filename): + """ + Returns absolute file path of the uid_filename + """ + return os.path.join(self._directory, self._directory_folder, uid_filename) + + def _fetch(self, key, presigned_url_expires: int = 0): + if key not in self: + log.exception(f'requested key={key} not found in {self}') + raise KeyError(key) + + metadata = self.get_metadata(key) + + file_path = metadata['filename_uid_path'] + if presigned_url_expires and presigned_url_expires > 0: + metadata['url'] = self.fs.url(file_path, expires=presigned_url_expires) + + return ShardFileReader(self.fs.open(file_path, 'rb')), metadata + + def delete(self, key): + return self._delete(key) + + +class FileSystemBackend(BaseFileStoreBackend): + shard_name: str = 'shard_{:03d}' + _shard_cls = FileSystemShard + + def __init__(self, settings): + super().__init__(settings) + + store_dir = self.get_conf(config_keys.filesystem_storage_path) + directory = os.path.expanduser(store_dir) + + self._directory = directory + self._storage_path = directory # common path for all from BaseCache + self._shard_count = int(self.get_conf(config_keys.filesystem_shards, pop=True)) + if self._shard_count < 1: + raise ValueError(f'{config_keys.filesystem_shards} must be 1 or more') + + log.debug('Initializing %s file_store instance', self) + fs = fsspec.filesystem('file') + + if not fs.exists(self._directory): + fs.makedirs(self._directory, exist_ok=True) + + self._shards = tuple( + self._shard_cls( + index=num, + directory=directory, + directory_folder=self.shard_name.format(num), + fs=fs, + **settings, + ) + for num in range(self._shard_count) + ) diff --git a/rhodecode/apps/file_store/backends/filesystem_legacy.py b/rhodecode/apps/file_store/backends/filesystem_legacy.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/file_store/backends/filesystem_legacy.py @@ -0,0 +1,278 @@ +# Copyright (C) 2016-2023 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ +import errno +import os +import hashlib +import functools +import time +import logging + +from .. import config_keys +from ..exceptions import FileOverSizeException +from ..backends.base import BaseFileStoreBackend, fsspec, BaseShard, ShardFileReader + +from ....lib.ext_json import json + +log = logging.getLogger(__name__) + + +class LegacyFileSystemShard(BaseShard): + # legacy ver + METADATA_VER = 'v2' + BACKEND_TYPE = config_keys.backend_legacy_filesystem + storage_type: str = 'dir_struct' + + # legacy suffix + metadata_suffix: str = '.meta' + + @classmethod + def _sub_store_from_filename(cls, filename): + return filename[:2] + + @classmethod + def apply_counter(cls, counter, filename): + name_counted = '%d-%s' % (counter, filename) + return name_counted + + @classmethod + def safe_make_dirs(cls, dir_path): + if not os.path.exists(dir_path): + try: + os.makedirs(dir_path) + except OSError as e: + if e.errno != errno.EEXIST: + raise + return + + @classmethod + def resolve_name(cls, name, directory): + """ + Resolves a unique name and the correct path. If a filename + for that path already exists then a numeric prefix with values > 0 will be + added, for example test.jpg -> 1-test.jpg etc. initially file would have 0 prefix. + + :param name: base name of file + :param directory: absolute directory path + """ + + counter = 0 + while True: + name_counted = cls.apply_counter(counter, name) + + # sub_store prefix to optimize disk usage, e.g some_path/ab/final_file + sub_store: str = cls._sub_store_from_filename(name_counted) + sub_store_path: str = os.path.join(directory, sub_store) + cls.safe_make_dirs(sub_store_path) + + path = os.path.join(sub_store_path, name_counted) + if not os.path.exists(path): + return name_counted, path + counter += 1 + + def __init__(self, index, directory, directory_folder, fs, **settings): + self._index: int = index + self._directory: str = directory + self._directory_folder: str = directory_folder + self.fs = fs + + @property + def dir_struct(self) -> str: + """Cache directory final path.""" + return os.path.join(self._directory, '0-') + + def _write_file(self, full_path, iterator, max_filesize, mode='wb'): + + # ensure dir exists + destination, _ = os.path.split(full_path) + if not self.fs.exists(destination): + self.fs.makedirs(destination) + + writer = self.fs.open(full_path, mode) + + digest = hashlib.sha256() + oversize_cleanup = False + with writer: + size = 0 + for chunk in iterator: + size += len(chunk) + digest.update(chunk) + writer.write(chunk) + + if max_filesize and size > max_filesize: + # free up the copied file, and raise exc + oversize_cleanup = True + break + + writer.flush() + # Get the file descriptor + fd = writer.fileno() + + # Sync the file descriptor to disk, helps with NFS cases... + os.fsync(fd) + + if oversize_cleanup: + self.fs.rm(full_path) + raise FileOverSizeException(f'given file is over size limit ({max_filesize}): {full_path}') + + sha256 = digest.hexdigest() + log.debug('written new artifact under %s, sha256: %s', full_path, sha256) + return size, sha256 + + def _store(self, key: str, uid_key, value_reader, max_filesize: int | None = None, metadata: dict | None = None, **kwargs): + + filename = key + uid_filename = uid_key + + # NOTE:, also apply N- Counter... + uid_filename, full_path = self.resolve_name(uid_filename, self._directory) + + # STORE METADATA + # TODO: make it compatible, and backward proof + _metadata = { + "version": self.METADATA_VER, + + "filename": filename, + "filename_uid_path": full_path, + "filename_uid": uid_filename, + "sha256": "", # NOTE: filled in by reader iteration + + "store_time": time.time(), + + "size": 0 + } + if metadata: + _metadata.update(metadata) + + read_iterator = iter(functools.partial(value_reader.read, 2**22), b'') + size, sha256 = self._write_file(full_path, read_iterator, max_filesize) + _metadata['size'] = size + _metadata['sha256'] = sha256 + + # after storing the artifacts, we write the metadata present + _metadata_file, metadata_file_path = self.get_metadata_filename(uid_filename) + + with self.fs.open(metadata_file_path, 'wb') as f: + f.write(json.dumps(_metadata)) + + return uid_filename, _metadata + + def store_path(self, uid_filename): + """ + Returns absolute file path of the uid_filename + """ + prefix_dir = '' + if '/' in uid_filename: + prefix_dir, filename = uid_filename.split('/') + sub_store = self._sub_store_from_filename(filename) + else: + sub_store = self._sub_store_from_filename(uid_filename) + + return os.path.join(self._directory, prefix_dir, sub_store, uid_filename) + + def metadata_convert(self, uid_filename, metadata): + # NOTE: backward compat mode here... this is for file created PRE 5.2 system + if 'meta_ver' in metadata: + full_path = self.store_path(uid_filename) + metadata = { + "_converted": True, + "_org": metadata, + "version": self.METADATA_VER, + "store_type": self.BACKEND_TYPE, + + "filename": metadata['filename'], + "filename_uid_path": full_path, + "filename_uid": uid_filename, + "sha256": metadata['sha256'], + + "store_time": metadata['time'], + + "size": metadata['size'] + } + return metadata + + def _fetch(self, key, presigned_url_expires: int = 0): + if key not in self: + log.exception(f'requested key={key} not found in {self}') + raise KeyError(key) + + metadata = self.get_metadata(key) + + file_path = metadata['filename_uid_path'] + if presigned_url_expires and presigned_url_expires > 0: + metadata['url'] = self.fs.url(file_path, expires=presigned_url_expires) + + return ShardFileReader(self.fs.open(file_path, 'rb')), metadata + + def delete(self, key): + return self._delete(key) + + def _delete(self, key): + if key not in self: + log.exception(f'requested key={key} not found in {self}') + raise KeyError(key) + + metadata = self.get_metadata(key) + metadata_file, metadata_file_path = self.get_metadata_filename(key) + artifact_file_path = metadata['filename_uid_path'] + self.fs.rm(artifact_file_path) + self.fs.rm(metadata_file_path) + + def get_metadata_filename(self, uid_filename) -> tuple[str, str]: + + metadata_file: str = f'{uid_filename}{self.metadata_suffix}' + uid_path_in_store = self.store_path(uid_filename) + + metadata_file_path = f'{uid_path_in_store}{self.metadata_suffix}' + return metadata_file, metadata_file_path + + +class LegacyFileSystemBackend(BaseFileStoreBackend): + _shard_cls = LegacyFileSystemShard + + def __init__(self, settings): + super().__init__(settings) + + store_dir = self.get_conf(config_keys.legacy_filesystem_storage_path) + directory = os.path.expanduser(store_dir) + + self._directory = directory + self._storage_path = directory # common path for all from BaseCache + + log.debug('Initializing %s file_store instance', self) + fs = fsspec.filesystem('file') + + if not fs.exists(self._directory): + fs.makedirs(self._directory, exist_ok=True) + + # legacy system uses single shard + self._shards = tuple( + [ + self._shard_cls( + index=0, + directory=directory, + directory_folder='', + fs=fs, + **settings, + ) + ] + ) + + @classmethod + def get_shard_index(cls, filename: str, num_shards) -> int: + # legacy filesystem doesn't use shards, and always uses single shard + return 0 diff --git a/rhodecode/apps/file_store/backends/local_store.py b/rhodecode/apps/file_store/backends/local_store.py deleted file mode 100755 --- a/rhodecode/apps/file_store/backends/local_store.py +++ /dev/null @@ -1,268 +0,0 @@ -# Copyright (C) 2016-2023 RhodeCode GmbH -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License, version 3 -# (only), as published by the Free Software Foundation. -# -# 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 Affero General Public License -# along with this program. If not, see . -# -# This program is dual-licensed. If you wish to learn more about the -# RhodeCode Enterprise Edition, including its added features, Support services, -# and proprietary license terms, please see https://rhodecode.com/licenses/ - -import os -import time -import errno -import hashlib - -from rhodecode.lib.ext_json import json -from rhodecode.apps.file_store import utils -from rhodecode.apps.file_store.extensions import resolve_extensions -from rhodecode.apps.file_store.exceptions import ( - FileNotAllowedException, FileOverSizeException) - -METADATA_VER = 'v1' - - -def safe_make_dirs(dir_path): - if not os.path.exists(dir_path): - try: - os.makedirs(dir_path) - except OSError as e: - if e.errno != errno.EEXIST: - raise - return - - -class LocalFileStorage(object): - - @classmethod - def apply_counter(cls, counter, filename): - name_counted = '%d-%s' % (counter, filename) - return name_counted - - @classmethod - def resolve_name(cls, name, directory): - """ - Resolves a unique name and the correct path. If a filename - for that path already exists then a numeric prefix with values > 0 will be - added, for example test.jpg -> 1-test.jpg etc. initially file would have 0 prefix. - - :param name: base name of file - :param directory: absolute directory path - """ - - counter = 0 - while True: - name_counted = cls.apply_counter(counter, name) - - # sub_store prefix to optimize disk usage, e.g some_path/ab/final_file - sub_store = cls._sub_store_from_filename(name_counted) - sub_store_path = os.path.join(directory, sub_store) - safe_make_dirs(sub_store_path) - - path = os.path.join(sub_store_path, name_counted) - if not os.path.exists(path): - return name_counted, path - counter += 1 - - @classmethod - def _sub_store_from_filename(cls, filename): - return filename[:2] - - @classmethod - def calculate_path_hash(cls, file_path): - """ - Efficient calculation of file_path sha256 sum - - :param file_path: - :return: sha256sum - """ - digest = hashlib.sha256() - with open(file_path, 'rb') as f: - for chunk in iter(lambda: f.read(1024 * 100), b""): - digest.update(chunk) - - return digest.hexdigest() - - def __init__(self, base_path, extension_groups=None): - - """ - Local file storage - - :param base_path: the absolute base path where uploads are stored - :param extension_groups: extensions string - """ - - extension_groups = extension_groups or ['any'] - self.base_path = base_path - self.extensions = resolve_extensions([], groups=extension_groups) - - def __repr__(self): - return f'{self.__class__}@{self.base_path}' - - def store_path(self, filename): - """ - Returns absolute file path of the filename, joined to the - base_path. - - :param filename: base name of file - """ - prefix_dir = '' - if '/' in filename: - prefix_dir, filename = filename.split('/') - sub_store = self._sub_store_from_filename(filename) - else: - sub_store = self._sub_store_from_filename(filename) - return os.path.join(self.base_path, prefix_dir, sub_store, filename) - - def delete(self, filename): - """ - Deletes the filename. Filename is resolved with the - absolute path based on base_path. If file does not exist, - returns **False**, otherwise **True** - - :param filename: base name of file - """ - if self.exists(filename): - os.remove(self.store_path(filename)) - return True - return False - - def exists(self, filename): - """ - Checks if file exists. Resolves filename's absolute - path based on base_path. - - :param filename: file_uid name of file, e.g 0-f62b2b2d-9708-4079-a071-ec3f958448d4.svg - """ - return os.path.exists(self.store_path(filename)) - - def filename_allowed(self, filename, extensions=None): - """Checks if a filename has an allowed extension - - :param filename: base name of file - :param extensions: iterable of extensions (or self.extensions) - """ - _, ext = os.path.splitext(filename) - return self.extension_allowed(ext, extensions) - - def extension_allowed(self, ext, extensions=None): - """ - Checks if an extension is permitted. Both e.g. ".jpg" and - "jpg" can be passed in. Extension lookup is case-insensitive. - - :param ext: extension to check - :param extensions: iterable of extensions to validate against (or self.extensions) - """ - def normalize_ext(_ext): - if _ext.startswith('.'): - _ext = _ext[1:] - return _ext.lower() - - extensions = extensions or self.extensions - if not extensions: - return True - - ext = normalize_ext(ext) - - return ext in [normalize_ext(x) for x in extensions] - - def save_file(self, file_obj, filename, directory=None, extensions=None, - extra_metadata=None, max_filesize=None, randomized_name=True, **kwargs): - """ - Saves a file object to the uploads location. - Returns the resolved filename, i.e. the directory + - the (randomized/incremented) base name. - - :param file_obj: **cgi.FieldStorage** object (or similar) - :param filename: original filename - :param directory: relative path of sub-directory - :param extensions: iterable of allowed extensions, if not default - :param max_filesize: maximum size of file that should be allowed - :param randomized_name: generate random generated UID or fixed based on the filename - :param extra_metadata: extra JSON metadata to store next to the file with .meta suffix - - """ - - extensions = extensions or self.extensions - - if not self.filename_allowed(filename, extensions): - raise FileNotAllowedException() - - if directory: - dest_directory = os.path.join(self.base_path, directory) - else: - dest_directory = self.base_path - - safe_make_dirs(dest_directory) - - uid_filename = utils.uid_filename(filename, randomized=randomized_name) - - # resolve also produces special sub-dir for file optimized store - filename, path = self.resolve_name(uid_filename, dest_directory) - stored_file_dir = os.path.dirname(path) - - no_body_seek = kwargs.pop('no_body_seek', False) - if no_body_seek: - pass - else: - file_obj.seek(0) - - with open(path, "wb") as dest: - length = 256 * 1024 - while 1: - buf = file_obj.read(length) - if not buf: - break - dest.write(buf) - - metadata = {} - if extra_metadata: - metadata = extra_metadata - - size = os.stat(path).st_size - - if max_filesize and size > max_filesize: - # free up the copied file, and raise exc - os.remove(path) - raise FileOverSizeException() - - file_hash = self.calculate_path_hash(path) - - metadata.update({ - "filename": filename, - "size": size, - "time": time.time(), - "sha256": file_hash, - "meta_ver": METADATA_VER - }) - - filename_meta = filename + '.meta' - with open(os.path.join(stored_file_dir, filename_meta), "wb") as dest_meta: - dest_meta.write(json.dumps(metadata)) - - if directory: - filename = os.path.join(directory, filename) - - return filename, metadata - - def get_metadata(self, filename, ignore_missing=False): - """ - Reads JSON stored metadata for a file - - :param filename: - :return: - """ - filename = self.store_path(filename) - filename_meta = filename + '.meta' - if ignore_missing and not os.path.isfile(filename_meta): - return {} - with open(filename_meta, "rb") as source_meta: - return json.loads(source_meta.read()) diff --git a/rhodecode/apps/file_store/backends/objectstore.py b/rhodecode/apps/file_store/backends/objectstore.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/file_store/backends/objectstore.py @@ -0,0 +1,184 @@ +# Copyright (C) 2016-2023 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import os +import hashlib +import functools +import time +import logging + +from .. import config_keys +from ..exceptions import FileOverSizeException +from ..backends.base import BaseFileStoreBackend, fsspec, BaseShard, ShardFileReader + +from ....lib.ext_json import json + +log = logging.getLogger(__name__) + + +class S3Shard(BaseShard): + METADATA_VER = 'v2' + BACKEND_TYPE = config_keys.backend_objectstore + storage_type: str = 'bucket' + + def __init__(self, index, bucket, bucket_folder, fs, **settings): + self._index: int = index + self._bucket_main: str = bucket + self._bucket_folder: str = bucket_folder + + self.fs = fs + + @property + def bucket(self) -> str: + """Cache bucket final path.""" + return os.path.join(self._bucket_main, self._bucket_folder) + + def _write_file(self, full_path, iterator, max_filesize, mode='wb'): + + # ensure dir exists + destination, _ = os.path.split(full_path) + if not self.fs.exists(destination): + self.fs.makedirs(destination) + + writer = self.fs.open(full_path, mode) + + digest = hashlib.sha256() + oversize_cleanup = False + with writer: + size = 0 + for chunk in iterator: + size += len(chunk) + digest.update(chunk) + writer.write(chunk) + + if max_filesize and size > max_filesize: + oversize_cleanup = True + # free up the copied file, and raise exc + break + + if oversize_cleanup: + self.fs.rm(full_path) + raise FileOverSizeException(f'given file is over size limit ({max_filesize}): {full_path}') + + sha256 = digest.hexdigest() + log.debug('written new artifact under %s, sha256: %s', full_path, sha256) + return size, sha256 + + def _store(self, key: str, uid_key, value_reader, max_filesize: int | None = None, metadata: dict | None = None, **kwargs): + + filename = key + uid_filename = uid_key + full_path = self.store_path(uid_filename) + + # STORE METADATA + _metadata = { + "version": self.METADATA_VER, + "store_type": self.BACKEND_TYPE, + + "filename": filename, + "filename_uid_path": full_path, + "filename_uid": uid_filename, + "sha256": "", # NOTE: filled in by reader iteration + + "store_time": time.time(), + + "size": 0 + } + + if metadata: + if kwargs.pop('import_mode', False): + # in import mode, we don't need to compute metadata, we just take the old version + _metadata["import_mode"] = True + else: + _metadata.update(metadata) + + read_iterator = iter(functools.partial(value_reader.read, 2**22), b'') + size, sha256 = self._write_file(full_path, read_iterator, max_filesize) + _metadata['size'] = size + _metadata['sha256'] = sha256 + + # after storing the artifacts, we write the metadata present + metadata_file, metadata_file_path = self.get_metadata_filename(uid_key) + + with self.fs.open(metadata_file_path, 'wb') as f: + f.write(json.dumps(_metadata)) + + return uid_filename, _metadata + + def store_path(self, uid_filename): + """ + Returns absolute file path of the uid_filename + """ + return os.path.join(self._bucket_main, self._bucket_folder, uid_filename) + + def _fetch(self, key, presigned_url_expires: int = 0): + if key not in self: + log.exception(f'requested key={key} not found in {self}') + raise KeyError(key) + + metadata_file, metadata_file_path = self.get_metadata_filename(key) + with self.fs.open(metadata_file_path, 'rb') as f: + metadata = json.loads(f.read()) + + file_path = metadata['filename_uid_path'] + if presigned_url_expires and presigned_url_expires > 0: + metadata['url'] = self.fs.url(file_path, expires=presigned_url_expires) + + return ShardFileReader(self.fs.open(file_path, 'rb')), metadata + + def delete(self, key): + return self._delete(key) + + +class ObjectStoreBackend(BaseFileStoreBackend): + shard_name: str = 'shard-{:03d}' + _shard_cls = S3Shard + + def __init__(self, settings): + super().__init__(settings) + + self._shard_count = int(self.get_conf(config_keys.objectstore_bucket_shards, pop=True)) + if self._shard_count < 1: + raise ValueError('cache_shards must be 1 or more') + + self._bucket = settings.pop(config_keys.objectstore_bucket) + if not self._bucket: + raise ValueError(f'{config_keys.objectstore_bucket} needs to have a value') + + objectstore_url = self.get_conf(config_keys.objectstore_url) + key = settings.pop(config_keys.objectstore_key) + secret = settings.pop(config_keys.objectstore_secret) + + self._storage_path = objectstore_url # common path for all from BaseCache + log.debug('Initializing %s file_store instance', self) + fs = fsspec.filesystem('s3', anon=False, endpoint_url=objectstore_url, key=key, secret=secret) + + # init main bucket + if not fs.exists(self._bucket): + fs.mkdir(self._bucket) + + self._shards = tuple( + self._shard_cls( + index=num, + bucket=self._bucket, + bucket_folder=self.shard_name.format(num), + fs=fs, + **settings, + ) + for num in range(self._shard_count) + ) diff --git a/rhodecode/apps/file_store/config_keys.py b/rhodecode/apps/file_store/config_keys.py --- a/rhodecode/apps/file_store/config_keys.py +++ b/rhodecode/apps/file_store/config_keys.py @@ -20,6 +20,38 @@ # Definition of setting keys used to configure this module. Defined here to # avoid repetition of keys throughout the module. -enabled = 'file_store.enabled' -backend = 'file_store.backend' -store_path = 'file_store.storage_path' +# OLD and deprecated keys not used anymore +deprecated_enabled = 'file_store.enabled' +deprecated_backend = 'file_store.backend' +deprecated_store_path = 'file_store.storage_path' + + +backend_type = 'file_store.backend.type' + +backend_legacy_filesystem = 'filesystem_v1' +backend_filesystem = 'filesystem_v2' +backend_objectstore = 'objectstore' + +backend_types = [ + backend_legacy_filesystem, + backend_filesystem, + backend_objectstore, +] + +# filesystem_v1 legacy +legacy_filesystem_storage_path = 'file_store.filesystem_v1.storage_path' + + +# filesystem_v2 new option +filesystem_storage_path = 'file_store.filesystem_v2.storage_path' +filesystem_shards = 'file_store.filesystem_v2.shards' + +# objectstore +objectstore_url = 'file_store.objectstore.url' +objectstore_bucket = 'file_store.objectstore.bucket' +objectstore_bucket_shards = 'file_store.objectstore.bucket_shards' + +objectstore_region = 'file_store.objectstore.region' +objectstore_key = 'file_store.objectstore.key' +objectstore_secret = 'file_store.objectstore.secret' + diff --git a/rhodecode/apps/file_store/tests/__init__.py b/rhodecode/apps/file_store/tests/__init__.py --- a/rhodecode/apps/file_store/tests/__init__.py +++ b/rhodecode/apps/file_store/tests/__init__.py @@ -16,3 +16,42 @@ # RhodeCode Enterprise Edition, including its added features, Support services, # and proprietary license terms, please see https://rhodecode.com/licenses/ +import os +import random +import tempfile +import string + +import pytest + +from rhodecode.apps.file_store import utils as store_utils + + +@pytest.fixture() +def file_store_instance(ini_settings): + config = ini_settings + f_store = store_utils.get_filestore_backend(config=config, always_init=True) + return f_store + + +@pytest.fixture +def random_binary_file(): + # Generate random binary data + data = bytearray(random.getrandbits(8) for _ in range(1024 * 512)) # 512 KB of random data + + # Create a temporary file + temp_file = tempfile.NamedTemporaryFile(delete=False) + filename = temp_file.name + + try: + # Write the random binary data to the file + temp_file.write(data) + temp_file.seek(0) # Rewind the file pointer to the beginning + yield filename, temp_file + finally: + # Close and delete the temporary file after the test + temp_file.close() + os.remove(filename) + + +def generate_random_filename(length=10): + return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) \ No newline at end of file diff --git a/rhodecode/apps/file_store/tests/test_filestore_backends.py b/rhodecode/apps/file_store/tests/test_filestore_backends.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/file_store/tests/test_filestore_backends.py @@ -0,0 +1,128 @@ +# Copyright (C) 2010-2023 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ +import pytest + +from rhodecode.apps import file_store +from rhodecode.apps.file_store import config_keys +from rhodecode.apps.file_store.backends.filesystem_legacy import LegacyFileSystemBackend +from rhodecode.apps.file_store.backends.filesystem import FileSystemBackend +from rhodecode.apps.file_store.backends.objectstore import ObjectStoreBackend +from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException + +from rhodecode.apps.file_store import utils as store_utils +from rhodecode.apps.file_store.tests import random_binary_file, file_store_instance + + +class TestFileStoreBackends: + + @pytest.mark.parametrize('backend_type, expected_instance', [ + (config_keys.backend_legacy_filesystem, LegacyFileSystemBackend), + (config_keys.backend_filesystem, FileSystemBackend), + (config_keys.backend_objectstore, ObjectStoreBackend), + ]) + def test_get_backend(self, backend_type, expected_instance, ini_settings): + config = ini_settings + config[config_keys.backend_type] = backend_type + f_store = store_utils.get_filestore_backend(config=config, always_init=True) + assert isinstance(f_store, expected_instance) + + @pytest.mark.parametrize('backend_type, expected_instance', [ + (config_keys.backend_legacy_filesystem, LegacyFileSystemBackend), + (config_keys.backend_filesystem, FileSystemBackend), + (config_keys.backend_objectstore, ObjectStoreBackend), + ]) + def test_store_and_read(self, backend_type, expected_instance, ini_settings, random_binary_file): + filename, temp_file = random_binary_file + config = ini_settings + config[config_keys.backend_type] = backend_type + f_store = store_utils.get_filestore_backend(config=config, always_init=True) + metadata = { + 'user_uploaded': { + 'username': 'user1', + 'user_id': 10, + 'ip': '10.20.30.40' + } + } + store_fid, metadata = f_store.store(filename, temp_file, extra_metadata=metadata) + assert store_fid + assert metadata + + # read-after write + reader, metadata2 = f_store.fetch(store_fid) + assert reader + assert metadata2['filename'] == filename + + @pytest.mark.parametrize('backend_type, expected_instance', [ + (config_keys.backend_legacy_filesystem, LegacyFileSystemBackend), + (config_keys.backend_filesystem, FileSystemBackend), + (config_keys.backend_objectstore, ObjectStoreBackend), + ]) + def test_store_file_not_allowed(self, backend_type, expected_instance, ini_settings, random_binary_file): + filename, temp_file = random_binary_file + config = ini_settings + config[config_keys.backend_type] = backend_type + f_store = store_utils.get_filestore_backend(config=config, always_init=True) + with pytest.raises(FileNotAllowedException): + f_store.store('notallowed.exe', temp_file, extensions=['.txt']) + + @pytest.mark.parametrize('backend_type, expected_instance', [ + (config_keys.backend_legacy_filesystem, LegacyFileSystemBackend), + (config_keys.backend_filesystem, FileSystemBackend), + (config_keys.backend_objectstore, ObjectStoreBackend), + ]) + def test_store_file_over_size(self, backend_type, expected_instance, ini_settings, random_binary_file): + filename, temp_file = random_binary_file + config = ini_settings + config[config_keys.backend_type] = backend_type + f_store = store_utils.get_filestore_backend(config=config, always_init=True) + with pytest.raises(FileOverSizeException): + f_store.store('toobig.exe', temp_file, extensions=['.exe'], max_filesize=124) + + @pytest.mark.parametrize('backend_type, expected_instance, extra_conf', [ + (config_keys.backend_legacy_filesystem, LegacyFileSystemBackend, {}), + (config_keys.backend_filesystem, FileSystemBackend, {config_keys.filesystem_storage_path: '/tmp/test-fs-store'}), + (config_keys.backend_objectstore, ObjectStoreBackend, {config_keys.objectstore_bucket: 'test-bucket'}), + ]) + def test_store_stats_and_keys(self, backend_type, expected_instance, extra_conf, ini_settings, random_binary_file): + config = ini_settings + config[config_keys.backend_type] = backend_type + config.update(extra_conf) + + f_store = store_utils.get_filestore_backend(config=config, always_init=True) + + # purge storage before running + for shard, k in f_store.iter_artifacts(): + f_store.delete(k) + + for i in range(10): + filename, temp_file = random_binary_file + + metadata = { + 'user_uploaded': { + 'username': 'user1', + 'user_id': 10, + 'ip': '10.20.30.40' + } + } + store_fid, metadata = f_store.store(filename, temp_file, extra_metadata=metadata) + assert store_fid + assert metadata + + cnt, size, meta = f_store.get_statistics() + assert cnt == 10 + assert 10 == len(list(f_store.iter_keys())) diff --git a/rhodecode/apps/file_store/tests/test_filestore_filesystem_backend.py b/rhodecode/apps/file_store/tests/test_filestore_filesystem_backend.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/file_store/tests/test_filestore_filesystem_backend.py @@ -0,0 +1,52 @@ +# Copyright (C) 2010-2023 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ +import pytest + +from rhodecode.apps.file_store import utils as store_utils +from rhodecode.apps.file_store import config_keys +from rhodecode.apps.file_store.tests import generate_random_filename + + +@pytest.fixture() +def file_store_filesystem_instance(ini_settings): + config = ini_settings + config[config_keys.backend_type] = config_keys.backend_filesystem + f_store = store_utils.get_filestore_backend(config=config, always_init=True) + return f_store + + +class TestFileStoreFileSystemBackend: + + @pytest.mark.parametrize('filename', [generate_random_filename() for _ in range(10)]) + def test_get_shard_number(self, filename, file_store_filesystem_instance): + shard_number = file_store_filesystem_instance.get_shard_index(filename, len(file_store_filesystem_instance._shards)) + # Check that the shard number is between 0 and max-shards + assert 0 <= shard_number <= len(file_store_filesystem_instance._shards) + + @pytest.mark.parametrize('filename, expected_shard_num', [ + ('my-name-1', 3), + ('my-name-2', 2), + ('my-name-3', 4), + ('my-name-4', 1), + + ('rhodecode-enterprise-ce', 5), + ('rhodecode-enterprise-ee', 6), + ]) + def test_get_shard_number_consistency(self, filename, expected_shard_num, file_store_filesystem_instance): + shard_number = file_store_filesystem_instance.get_shard_index(filename, len(file_store_filesystem_instance._shards)) + assert expected_shard_num == shard_number diff --git a/rhodecode/apps/file_store/tests/test_filestore_legacy_and_v2_compatability.py b/rhodecode/apps/file_store/tests/test_filestore_legacy_and_v2_compatability.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/file_store/tests/test_filestore_legacy_and_v2_compatability.py @@ -0,0 +1,17 @@ +# Copyright (C) 2010-2023 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ \ No newline at end of file diff --git a/rhodecode/apps/file_store/tests/test_filestore_legacy_backend.py b/rhodecode/apps/file_store/tests/test_filestore_legacy_backend.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/file_store/tests/test_filestore_legacy_backend.py @@ -0,0 +1,52 @@ +# Copyright (C) 2010-2023 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ +import pytest + +from rhodecode.apps.file_store import utils as store_utils +from rhodecode.apps.file_store import config_keys +from rhodecode.apps.file_store.tests import generate_random_filename + + +@pytest.fixture() +def file_store_legacy_instance(ini_settings): + config = ini_settings + config[config_keys.backend_type] = config_keys.backend_legacy_filesystem + f_store = store_utils.get_filestore_backend(config=config, always_init=True) + return f_store + + +class TestFileStoreLegacyBackend: + + @pytest.mark.parametrize('filename', [generate_random_filename() for _ in range(10)]) + def test_get_shard_number(self, filename, file_store_legacy_instance): + shard_number = file_store_legacy_instance.get_shard_index(filename, len(file_store_legacy_instance._shards)) + # Check that the shard number is 0 for legacy filesystem store we don't use shards + assert shard_number == 0 + + @pytest.mark.parametrize('filename, expected_shard_num', [ + ('my-name-1', 0), + ('my-name-2', 0), + ('my-name-3', 0), + ('my-name-4', 0), + + ('rhodecode-enterprise-ce', 0), + ('rhodecode-enterprise-ee', 0), + ]) + def test_get_shard_number_consistency(self, filename, expected_shard_num, file_store_legacy_instance): + shard_number = file_store_legacy_instance.get_shard_index(filename, len(file_store_legacy_instance._shards)) + assert expected_shard_num == shard_number diff --git a/rhodecode/apps/file_store/tests/test_filestore_objectstore_backend.py b/rhodecode/apps/file_store/tests/test_filestore_objectstore_backend.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/file_store/tests/test_filestore_objectstore_backend.py @@ -0,0 +1,52 @@ +# Copyright (C) 2010-2023 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ +import pytest + +from rhodecode.apps.file_store import utils as store_utils +from rhodecode.apps.file_store import config_keys +from rhodecode.apps.file_store.tests import generate_random_filename + + +@pytest.fixture() +def file_store_objectstore_instance(ini_settings): + config = ini_settings + config[config_keys.backend_type] = config_keys.backend_objectstore + f_store = store_utils.get_filestore_backend(config=config, always_init=True) + return f_store + + +class TestFileStoreObjectStoreBackend: + + @pytest.mark.parametrize('filename', [generate_random_filename() for _ in range(10)]) + def test_get_shard_number(self, filename, file_store_objectstore_instance): + shard_number = file_store_objectstore_instance.get_shard_index(filename, len(file_store_objectstore_instance._shards)) + # Check that the shard number is between 0 and shards + assert 0 <= shard_number <= len(file_store_objectstore_instance._shards) + + @pytest.mark.parametrize('filename, expected_shard_num', [ + ('my-name-1', 3), + ('my-name-2', 2), + ('my-name-3', 4), + ('my-name-4', 1), + + ('rhodecode-enterprise-ce', 5), + ('rhodecode-enterprise-ee', 6), + ]) + def test_get_shard_number_consistency(self, filename, expected_shard_num, file_store_objectstore_instance): + shard_number = file_store_objectstore_instance.get_shard_index(filename, len(file_store_objectstore_instance._shards)) + assert expected_shard_num == shard_number diff --git a/rhodecode/apps/file_store/tests/test_upload_file.py b/rhodecode/apps/file_store/tests/test_upload_file.py --- a/rhodecode/apps/file_store/tests/test_upload_file.py +++ b/rhodecode/apps/file_store/tests/test_upload_file.py @@ -15,13 +15,16 @@ # This program is dual-licensed. If you wish to learn more about the # RhodeCode Enterprise Edition, including its added features, Support services, # and proprietary license terms, please see https://rhodecode.com/licenses/ + import os + import pytest from rhodecode.lib.ext_json import json from rhodecode.model.auth_token import AuthTokenModel from rhodecode.model.db import Session, FileStore, Repository, User -from rhodecode.apps.file_store import utils, config_keys +from rhodecode.apps.file_store import utils as store_utils +from rhodecode.apps.file_store import config_keys from rhodecode.tests import TestController from rhodecode.tests.routes import route_path @@ -29,27 +32,61 @@ from rhodecode.tests.routes import route class TestFileStoreViews(TestController): + @pytest.fixture() + def create_artifact_factory(self, tmpdir, ini_settings): + + def factory(user_id, content, f_name='example.txt'): + + config = ini_settings + config[config_keys.backend_type] = config_keys.backend_legacy_filesystem + + f_store = store_utils.get_filestore_backend(config) + + filesystem_file = os.path.join(str(tmpdir), f_name) + with open(filesystem_file, 'wt') as f: + f.write(content) + + with open(filesystem_file, 'rb') as f: + store_uid, metadata = f_store.store(f_name, f, metadata={'filename': f_name}) + os.remove(filesystem_file) + + entry = FileStore.create( + file_uid=store_uid, filename=metadata["filename"], + file_hash=metadata["sha256"], file_size=metadata["size"], + file_display_name='file_display_name', + file_description='repo artifact `{}`'.format(metadata["filename"]), + check_acl=True, user_id=user_id, + ) + Session().add(entry) + Session().commit() + return entry + return factory + @pytest.mark.parametrize("fid, content, exists", [ ('abcde-0.jpg', "xxxxx", True), ('abcde-0.exe', "1234567", True), ('abcde-0.jpg', "xxxxx", False), ]) - def test_get_files_from_store(self, fid, content, exists, tmpdir, user_util): + def test_get_files_from_store(self, fid, content, exists, tmpdir, user_util, ini_settings): user = self.log_user() user_id = user['user_id'] repo_id = user_util.create_repo().repo_id - store_path = self.app._pyramid_settings[config_keys.store_path] + + config = ini_settings + config[config_keys.backend_type] = config_keys.backend_legacy_filesystem + store_uid = fid if exists: status = 200 - store = utils.get_file_storage({config_keys.store_path: store_path}) + f_store = store_utils.get_filestore_backend(config) filesystem_file = os.path.join(str(tmpdir), fid) with open(filesystem_file, 'wt') as f: f.write(content) with open(filesystem_file, 'rb') as f: - store_uid, metadata = store.save_file(f, fid, extra_metadata={'filename': fid}) + store_uid, metadata = f_store.store(fid, f, metadata={'filename': fid}) + os.remove(filesystem_file) entry = FileStore.create( file_uid=store_uid, filename=metadata["filename"], @@ -69,14 +106,10 @@ class TestFileStoreViews(TestController) if exists: assert response.text == content - file_store_path = os.path.dirname(store.resolve_name(store_uid, store_path)[1]) - metadata_file = os.path.join(file_store_path, store_uid + '.meta') - assert os.path.exists(metadata_file) - with open(metadata_file, 'rb') as f: - json_data = json.loads(f.read()) - assert json_data - assert 'size' in json_data + metadata = f_store.get_metadata(store_uid) + + assert 'size' in metadata def test_upload_files_without_content_to_store(self): self.log_user() @@ -112,32 +145,6 @@ class TestFileStoreViews(TestController) assert response.json['store_fid'] - @pytest.fixture() - def create_artifact_factory(self, tmpdir): - def factory(user_id, content): - store_path = self.app._pyramid_settings[config_keys.store_path] - store = utils.get_file_storage({config_keys.store_path: store_path}) - fid = 'example.txt' - - filesystem_file = os.path.join(str(tmpdir), fid) - with open(filesystem_file, 'wt') as f: - f.write(content) - - with open(filesystem_file, 'rb') as f: - store_uid, metadata = store.save_file(f, fid, extra_metadata={'filename': fid}) - - entry = FileStore.create( - file_uid=store_uid, filename=metadata["filename"], - file_hash=metadata["sha256"], file_size=metadata["size"], - file_display_name='file_display_name', - file_description='repo artifact `{}`'.format(metadata["filename"]), - check_acl=True, user_id=user_id, - ) - Session().add(entry) - Session().commit() - return entry - return factory - def test_download_file_non_scoped(self, user_util, create_artifact_factory): user = self.log_user() user_id = user['user_id'] diff --git a/rhodecode/apps/file_store/utils.py b/rhodecode/apps/file_store/utils.py --- a/rhodecode/apps/file_store/utils.py +++ b/rhodecode/apps/file_store/utils.py @@ -19,21 +19,84 @@ import io import uuid import pathlib +import s3fs + +from rhodecode.lib.hash_utils import sha256_safe +from rhodecode.apps.file_store import config_keys + + +file_store_meta = None + + +def get_filestore_config(config) -> dict: + + final_config = {} + + for k, v in config.items(): + if k.startswith('file_store'): + final_config[k] = v + + return final_config -def get_file_storage(settings): - from rhodecode.apps.file_store.backends.local_store import LocalFileStorage - from rhodecode.apps.file_store import config_keys - store_path = settings.get(config_keys.store_path) - return LocalFileStorage(base_path=store_path) +def get_filestore_backend(config, always_init=False): + """ + + usage:: + from rhodecode.apps.file_store import get_filestore_backend + f_store = get_filestore_backend(config=CONFIG) + + :param config: + :param always_init: + :return: + """ + + global file_store_meta + if file_store_meta is not None and not always_init: + return file_store_meta + + config = get_filestore_config(config) + backend = config[config_keys.backend_type] + + match backend: + case config_keys.backend_legacy_filesystem: + # Legacy backward compatible storage + from rhodecode.apps.file_store.backends.filesystem_legacy import LegacyFileSystemBackend + d_cache = LegacyFileSystemBackend( + settings=config + ) + case config_keys.backend_filesystem: + from rhodecode.apps.file_store.backends.filesystem import FileSystemBackend + d_cache = FileSystemBackend( + settings=config + ) + case config_keys.backend_objectstore: + from rhodecode.apps.file_store.backends.objectstore import ObjectStoreBackend + d_cache = ObjectStoreBackend( + settings=config + ) + case _: + raise ValueError( + f'file_store.backend.type only supports "{config_keys.backend_types}" got {backend}' + ) + + cache_meta = d_cache + return cache_meta def splitext(filename): - ext = ''.join(pathlib.Path(filename).suffixes) + final_ext = [] + for suffix in pathlib.Path(filename).suffixes: + if not suffix.isascii(): + continue + + suffix = " ".join(suffix.split()).replace(" ", "") + final_ext.append(suffix) + ext = ''.join(final_ext) return filename, ext -def uid_filename(filename, randomized=True): +def get_uid_filename(filename, randomized=True): """ Generates a randomized or stable (uuid) filename, preserving the original extension. @@ -46,10 +109,37 @@ def uid_filename(filename, randomized=Tr if randomized: uid = uuid.uuid4() else: - hash_key = '{}.{}'.format(filename, 'store') + store_suffix = "store" + hash_key = f'{filename}.{store_suffix}' uid = uuid.uuid5(uuid.NAMESPACE_URL, hash_key) return str(uid) + ext.lower() def bytes_to_file_obj(bytes_data): - return io.StringIO(bytes_data) + return io.BytesIO(bytes_data) + + +class ShardFileReader: + + def __init__(self, file_like_reader): + self._file_like_reader = file_like_reader + + def __getattr__(self, item): + if isinstance(self._file_like_reader, s3fs.core.S3File): + match item: + case 'name': + # S3 FileWrapper doesn't support name attribute, and we use it + return self._file_like_reader.full_name + case _: + return getattr(self._file_like_reader, item) + else: + return getattr(self._file_like_reader, item) + + +def archive_iterator(_reader, block_size: int = 4096 * 512): + # 4096 * 64 = 64KB + while 1: + data = _reader.read(block_size) + if not data: + break + yield data diff --git a/rhodecode/apps/file_store/views.py b/rhodecode/apps/file_store/views.py --- a/rhodecode/apps/file_store/views.py +++ b/rhodecode/apps/file_store/views.py @@ -17,12 +17,11 @@ # and proprietary license terms, please see https://rhodecode.com/licenses/ import logging - -from pyramid.response import FileResponse +from pyramid.response import Response from pyramid.httpexceptions import HTTPFound, HTTPNotFound from rhodecode.apps._base import BaseAppView -from rhodecode.apps.file_store import utils +from rhodecode.apps.file_store import utils as store_utils from rhodecode.apps.file_store.exceptions import ( FileNotAllowedException, FileOverSizeException) @@ -31,6 +30,7 @@ from rhodecode.lib import audit_logger from rhodecode.lib.auth import ( CSRFRequired, NotAnonymous, HasRepoPermissionAny, HasRepoGroupPermissionAny, LoginRequired) +from rhodecode.lib.str_utils import header_safe_str from rhodecode.lib.vcs.conf.mtypes import get_mimetypes_db from rhodecode.model.db import Session, FileStore, UserApiKeys @@ -42,7 +42,7 @@ class FileStoreView(BaseAppView): def load_default_context(self): c = self._get_local_tmpl_context() - self.storage = utils.get_file_storage(self.request.registry.settings) + self.f_store = store_utils.get_filestore_backend(self.request.registry.settings) return c def _guess_type(self, file_name): @@ -55,10 +55,10 @@ class FileStoreView(BaseAppView): return _content_type, _encoding def _serve_file(self, file_uid): - if not self.storage.exists(file_uid): - store_path = self.storage.store_path(file_uid) - log.debug('File with FID:%s not found in the store under `%s`', - file_uid, store_path) + if not self.f_store.filename_exists(file_uid): + store_path = self.f_store.store_path(file_uid) + log.warning('File with FID:%s not found in the store under `%s`', + file_uid, store_path) raise HTTPNotFound() db_obj = FileStore.get_by_store_uid(file_uid, safe=True) @@ -98,28 +98,25 @@ class FileStoreView(BaseAppView): FileStore.bump_access_counter(file_uid) - file_path = self.storage.store_path(file_uid) + file_name = db_obj.file_display_name content_type = 'application/octet-stream' - content_encoding = None - _content_type, _encoding = self._guess_type(file_path) + _content_type, _encoding = self._guess_type(file_name) if _content_type: content_type = _content_type # For file store we don't submit any session data, this logic tells the # Session lib to skip it setattr(self.request, '_file_response', True) - response = FileResponse( - file_path, request=self.request, - content_type=content_type, content_encoding=content_encoding) + reader, _meta = self.f_store.fetch(file_uid) - file_name = db_obj.file_display_name + response = Response(app_iter=store_utils.archive_iterator(reader)) - response.headers["Content-Disposition"] = ( - f'attachment; filename="{str(file_name)}"' - ) + response.content_type = str(content_type) + response.content_disposition = f'attachment; filename="{header_safe_str(file_name)}"' + response.headers["X-RC-Artifact-Id"] = str(db_obj.file_store_id) - response.headers["X-RC-Artifact-Desc"] = str(db_obj.file_description) + response.headers["X-RC-Artifact-Desc"] = header_safe_str(db_obj.file_description) response.headers["X-RC-Artifact-Sha256"] = str(db_obj.file_hash) return response @@ -147,8 +144,8 @@ class FileStoreView(BaseAppView): 'user_id': self._rhodecode_user.user_id, 'ip': self._rhodecode_user.ip_addr}} try: - store_uid, metadata = self.storage.save_file( - file_obj.file, filename, extra_metadata=metadata) + store_uid, metadata = self.f_store.store( + filename, file_obj.file, extra_metadata=metadata) except FileNotAllowedException: return {'store_fid': None, 'access_path': None, @@ -182,7 +179,7 @@ class FileStoreView(BaseAppView): def download_file(self): self.load_default_context() file_uid = self.request.matchdict['fid'] - log.debug('Requesting FID:%s from store %s', file_uid, self.storage) + log.debug('Requesting FID:%s from store %s', file_uid, self.f_store) return self._serve_file(file_uid) # in addition to @LoginRequired ACL is checked by scopes diff --git a/rhodecode/apps/repository/views/repo_commits.py b/rhodecode/apps/repository/views/repo_commits.py --- a/rhodecode/apps/repository/views/repo_commits.py +++ b/rhodecode/apps/repository/views/repo_commits.py @@ -601,26 +601,26 @@ class RepoCommitsView(RepoAppView): max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js try: - storage = store_utils.get_file_storage(self.request.registry.settings) - store_uid, metadata = storage.save_file( - file_obj.file, filename, extra_metadata=metadata, + f_store = store_utils.get_filestore_backend(self.request.registry.settings) + store_uid, metadata = f_store.store( + filename, file_obj.file, metadata=metadata, extensions=allowed_extensions, max_filesize=max_file_size) except FileNotAllowedException: self.request.response.status = 400 permitted_extensions = ', '.join(allowed_extensions) - error_msg = 'File `{}` is not allowed. ' \ - 'Only following extensions are permitted: {}'.format( - filename, permitted_extensions) + error_msg = f'File `{filename}` is not allowed. ' \ + f'Only following extensions are permitted: {permitted_extensions}' + return {'store_fid': None, 'access_path': None, 'error': error_msg} except FileOverSizeException: self.request.response.status = 400 limit_mb = h.format_byte_size_binary(max_file_size) + error_msg = f'File {filename} is exceeding allowed limit of {limit_mb}.' return {'store_fid': None, 'access_path': None, - 'error': 'File {} is exceeding allowed limit of {}.'.format( - filename, limit_mb)} + 'error': error_msg} try: entry = FileStore.create( diff --git a/rhodecode/apps/repository/views/repo_files.py b/rhodecode/apps/repository/views/repo_files.py --- a/rhodecode/apps/repository/views/repo_files.py +++ b/rhodecode/apps/repository/views/repo_files.py @@ -48,7 +48,7 @@ from rhodecode.lib.codeblocks import ( filenode_as_lines_tokens, filenode_as_annotated_lines_tokens) from rhodecode.lib.utils2 import convert_line_endings, detect_mode from rhodecode.lib.type_utils import str2bool -from rhodecode.lib.str_utils import safe_str, safe_int +from rhodecode.lib.str_utils import safe_str, safe_int, header_safe_str from rhodecode.lib.auth import ( LoginRequired, HasRepoPermissionAnyDecorator, CSRFRequired) from rhodecode.lib.vcs import path as vcspath @@ -820,7 +820,7 @@ class RepoFilesView(RepoAppView): "filename=\"{}\"; " \ "filename*=UTF-8\'\'{}".format(safe_path, encoded_path) - return safe_bytes(headers).decode('latin-1', errors='replace') + return header_safe_str(headers) @LoginRequired() @HasRepoPermissionAnyDecorator( diff --git a/rhodecode/apps/repository/views/repo_settings_advanced.py b/rhodecode/apps/repository/views/repo_settings_advanced.py --- a/rhodecode/apps/repository/views/repo_settings_advanced.py +++ b/rhodecode/apps/repository/views/repo_settings_advanced.py @@ -29,7 +29,7 @@ from rhodecode.lib import audit_logger from rhodecode.lib.auth import ( LoginRequired, HasRepoPermissionAnyDecorator, CSRFRequired, HasRepoPermissionAny) -from rhodecode.lib.exceptions import AttachedForksError, AttachedPullRequestsError +from rhodecode.lib.exceptions import AttachedForksError, AttachedPullRequestsError, AttachedArtifactsError from rhodecode.lib.utils2 import safe_int from rhodecode.lib.vcs import RepositoryError from rhodecode.model.db import Session, UserFollowing, User, Repository @@ -136,6 +136,9 @@ class RepoSettingsAdvancedView(RepoAppVi elif handle_forks == 'delete_forks': handle_forks = 'delete' + repo_advanced_url = h.route_path( + 'edit_repo_advanced', repo_name=self.db_repo_name, + _anchor='advanced-delete') try: old_data = self.db_repo.get_api_data() RepoModel().delete(self.db_repo, forks=handle_forks) @@ -158,9 +161,6 @@ class RepoSettingsAdvancedView(RepoAppVi category='success') Session().commit() except AttachedForksError: - repo_advanced_url = h.route_path( - 'edit_repo_advanced', repo_name=self.db_repo_name, - _anchor='advanced-delete') delete_anchor = h.link_to(_('detach or delete'), repo_advanced_url) h.flash(_('Cannot delete `{repo}` it still contains attached forks. ' 'Try using {delete_or_detach} option.') @@ -171,9 +171,6 @@ class RepoSettingsAdvancedView(RepoAppVi raise HTTPFound(repo_advanced_url) except AttachedPullRequestsError: - repo_advanced_url = h.route_path( - 'edit_repo_advanced', repo_name=self.db_repo_name, - _anchor='advanced-delete') attached_prs = len(self.db_repo.pull_requests_source + self.db_repo.pull_requests_target) h.flash( @@ -184,6 +181,16 @@ class RepoSettingsAdvancedView(RepoAppVi # redirect to advanced for forks handle action ? raise HTTPFound(repo_advanced_url) + except AttachedArtifactsError: + + attached_artifacts = len(self.db_repo.artifacts) + h.flash( + _('Cannot delete `{repo}` it still contains {num} attached artifacts. ' + 'Consider archiving the repository instead.').format( + repo=self.db_repo_name, num=attached_artifacts), category='warning') + + # redirect to advanced for forks handle action ? + raise HTTPFound(repo_advanced_url) except Exception: log.exception("Exception during deletion of repository") h.flash(_('An error occurred during deletion of `%s`') diff --git a/rhodecode/apps/ssh_support/__init__.py b/rhodecode/apps/ssh_support/__init__.py --- a/rhodecode/apps/ssh_support/__init__.py +++ b/rhodecode/apps/ssh_support/__init__.py @@ -37,7 +37,7 @@ def _sanitize_settings_and_apply_default settings_maker.make_setting(config_keys.ssh_key_generator_enabled, True, parser='bool') settings_maker.make_setting(config_keys.authorized_keys_file_path, '~/.ssh/authorized_keys_rhodecode') - settings_maker.make_setting(config_keys.wrapper_cmd, '') + settings_maker.make_setting(config_keys.wrapper_cmd, '/usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper-v2') settings_maker.make_setting(config_keys.authorized_keys_line_ssh_opts, '') settings_maker.make_setting(config_keys.ssh_hg_bin, '/usr/local/bin/rhodecode_bin/vcs_bin/hg') diff --git a/rhodecode/apps/ssh_support/config_keys.py b/rhodecode/apps/ssh_support/config_keys.py --- a/rhodecode/apps/ssh_support/config_keys.py +++ b/rhodecode/apps/ssh_support/config_keys.py @@ -23,7 +23,7 @@ generate_authorized_keyfile = 'ssh.gener authorized_keys_file_path = 'ssh.authorized_keys_file_path' authorized_keys_line_ssh_opts = 'ssh.authorized_keys_ssh_opts' ssh_key_generator_enabled = 'ssh.enable_ui_key_generator' -wrapper_cmd = 'ssh.wrapper_cmd' +wrapper_cmd = 'ssh.wrapper_cmd.v2' wrapper_allow_shell = 'ssh.wrapper_cmd_allow_shell' enable_debug_logging = 'ssh.enable_debug_logging' diff --git a/rhodecode/apps/ssh_support/lib/backends/base.py b/rhodecode/apps/ssh_support/lib/backends/base.py --- a/rhodecode/apps/ssh_support/lib/backends/base.py +++ b/rhodecode/apps/ssh_support/lib/backends/base.py @@ -157,7 +157,7 @@ class SshVcsServer(object): return exit_code, action == "push" def run(self, tunnel_extras=None): - self.hooks_protocol = self.settings['vcs.hooks.protocol'] + self.hooks_protocol = self.settings['vcs.hooks.protocol.v2'] tunnel_extras = tunnel_extras or {} extras = {} extras.update(tunnel_extras) diff --git a/rhodecode/apps/ssh_support/tests/test_server_git.py b/rhodecode/apps/ssh_support/tests/test_server_git.py --- a/rhodecode/apps/ssh_support/tests/test_server_git.py +++ b/rhodecode/apps/ssh_support/tests/test_server_git.py @@ -32,7 +32,7 @@ class GitServerCreator(object): config_data = { 'app:main': { 'ssh.executable.git': git_path, - 'vcs.hooks.protocol': 'http', + 'vcs.hooks.protocol.v2': 'celery', } } repo_name = 'test_git' diff --git a/rhodecode/apps/ssh_support/tests/test_server_hg.py b/rhodecode/apps/ssh_support/tests/test_server_hg.py --- a/rhodecode/apps/ssh_support/tests/test_server_hg.py +++ b/rhodecode/apps/ssh_support/tests/test_server_hg.py @@ -31,7 +31,7 @@ class MercurialServerCreator(object): config_data = { 'app:main': { 'ssh.executable.hg': hg_path, - 'vcs.hooks.protocol': 'http', + 'vcs.hooks.protocol.v2': 'celery', } } repo_name = 'test_hg' diff --git a/rhodecode/apps/ssh_support/tests/test_server_svn.py b/rhodecode/apps/ssh_support/tests/test_server_svn.py --- a/rhodecode/apps/ssh_support/tests/test_server_svn.py +++ b/rhodecode/apps/ssh_support/tests/test_server_svn.py @@ -29,7 +29,7 @@ class SubversionServerCreator(object): config_data = { 'app:main': { 'ssh.executable.svn': svn_path, - 'vcs.hooks.protocol': 'http', + 'vcs.hooks.protocol.v2': 'celery', } } repo_name = 'test-svn' diff --git a/rhodecode/authentication/routes.py b/rhodecode/authentication/routes.py --- a/rhodecode/authentication/routes.py +++ b/rhodecode/authentication/routes.py @@ -52,6 +52,7 @@ class AuthnRootResource(AuthnResourceBas """ This is the root traversal resource object for the authentication settings. """ + is_root = True def __init__(self): self._store = collections.OrderedDict() diff --git a/rhodecode/config/config_maker.py b/rhodecode/config/config_maker.py --- a/rhodecode/config/config_maker.py +++ b/rhodecode/config/config_maker.py @@ -52,7 +52,8 @@ def sanitize_settings_and_apply_defaults default=False, parser='bool') - logging_conf = jn(os.path.dirname(global_config.get('__file__')), 'logging.ini') + ini_loc = os.path.dirname(global_config.get('__file__')) + logging_conf = jn(ini_loc, 'logging.ini') settings_maker.enable_logging(logging_conf, level='INFO' if debug_enabled else 'DEBUG') # Default includes, possible to change as a user @@ -95,6 +96,11 @@ def sanitize_settings_and_apply_defaults settings_maker.make_setting('gzip_responses', False, parser='bool') settings_maker.make_setting('startup.import_repos', 'false', parser='bool') + # License settings. + settings_maker.make_setting('license.hide_license_info', False, parser='bool') + settings_maker.make_setting('license.import_path', '') + settings_maker.make_setting('license.import_path_mode', 'if-missing') + # statsd settings_maker.make_setting('statsd.enabled', False, parser='bool') settings_maker.make_setting('statsd.statsd_host', 'statsd-exporter', parser='string') @@ -106,7 +112,7 @@ def sanitize_settings_and_apply_defaults settings_maker.make_setting('vcs.svn.redis_conn', 'redis://redis:6379/0') settings_maker.make_setting('vcs.svn.proxy.enabled', True, parser='bool') settings_maker.make_setting('vcs.svn.proxy.host', 'http://svn:8090', parser='string') - settings_maker.make_setting('vcs.hooks.protocol', 'http') + settings_maker.make_setting('vcs.hooks.protocol.v2', 'celery') settings_maker.make_setting('vcs.hooks.host', '*') settings_maker.make_setting('vcs.scm_app_implementation', 'http') settings_maker.make_setting('vcs.server', '') @@ -116,6 +122,9 @@ def sanitize_settings_and_apply_defaults settings_maker.make_setting('vcs.start_server', 'false', parser='bool') settings_maker.make_setting('vcs.backends', 'hg, git, svn', parser='list') settings_maker.make_setting('vcs.connection_timeout', 3600, parser='int') + settings_maker.make_setting('vcs.git.lfs.storage_location', '/var/opt/rhodecode_repo_store/.cache/git_lfs_store') + settings_maker.make_setting('vcs.hg.largefiles.storage_location', + '/var/opt/rhodecode_repo_store/.cache/hg_largefiles_store') settings_maker.make_setting('vcs.methods.cache', True, parser='bool') @@ -152,6 +161,10 @@ def sanitize_settings_and_apply_defaults parser='file:ensured' ) + # celery + broker_url = settings_maker.make_setting('celery.broker_url', 'redis://redis:6379/8') + settings_maker.make_setting('celery.result_backend', broker_url) + settings_maker.make_setting('exception_tracker.send_email', False, parser='bool') settings_maker.make_setting('exception_tracker.email_prefix', '[RHODECODE ERROR]', default_when_empty=True) @@ -202,7 +215,7 @@ def sanitize_settings_and_apply_defaults settings_maker.make_setting('archive_cache.filesystem.retry_backoff', 1, parser='int') settings_maker.make_setting('archive_cache.filesystem.retry_attempts', 10, parser='int') - settings_maker.make_setting('archive_cache.objectstore.url', jn(default_cache_dir, 'archive_cache'), default_when_empty=True,) + settings_maker.make_setting('archive_cache.objectstore.url', 'http://s3-minio:9000', default_when_empty=True,) settings_maker.make_setting('archive_cache.objectstore.key', '') settings_maker.make_setting('archive_cache.objectstore.secret', '') settings_maker.make_setting('archive_cache.objectstore.region', 'eu-central-1') diff --git a/rhodecode/config/environment.py b/rhodecode/config/environment.py --- a/rhodecode/config/environment.py +++ b/rhodecode/config/environment.py @@ -16,8 +16,8 @@ # RhodeCode Enterprise Edition, including its added features, Support services, # and proprietary license terms, please see https://rhodecode.com/licenses/ -import os import logging + import rhodecode import collections @@ -30,6 +30,21 @@ from rhodecode.lib.vcs import connect_vc log = logging.getLogger(__name__) +def propagate_rhodecode_config(global_config, settings, config): + # Store the settings to make them available to other modules. + settings_merged = global_config.copy() + settings_merged.update(settings) + if config: + settings_merged.update(config) + + rhodecode.PYRAMID_SETTINGS = settings_merged + rhodecode.CONFIG = settings_merged + + if 'default_user_id' not in rhodecode.CONFIG: + rhodecode.CONFIG['default_user_id'] = utils.get_default_user_id() + log.debug('set rhodecode.CONFIG data') + + def load_pyramid_environment(global_config, settings): # Some parts of the code expect a merge of global and app settings. settings_merged = global_config.copy() @@ -75,11 +90,8 @@ def load_pyramid_environment(global_conf utils.configure_vcs(settings) - # Store the settings to make them available to other modules. - - rhodecode.PYRAMID_SETTINGS = settings_merged - rhodecode.CONFIG = settings_merged - rhodecode.CONFIG['default_user_id'] = utils.get_default_user_id() + # first run, to store data... + propagate_rhodecode_config(global_config, settings, {}) if vcs_server_enabled: connect_vcs(vcs_server_uri, utils.get_vcs_server_protocol(settings)) diff --git a/rhodecode/config/middleware.py b/rhodecode/config/middleware.py --- a/rhodecode/config/middleware.py +++ b/rhodecode/config/middleware.py @@ -35,7 +35,7 @@ from pyramid.renderers import render_to_ from rhodecode.model import meta from rhodecode.config import patches -from rhodecode.config.environment import load_pyramid_environment +from rhodecode.config.environment import load_pyramid_environment, propagate_rhodecode_config import rhodecode.events from rhodecode.config.config_maker import sanitize_settings_and_apply_defaults @@ -50,7 +50,7 @@ from rhodecode.lib.utils2 import Attribu from rhodecode.lib.exc_tracking import store_exception, format_exc from rhodecode.subscribers import ( scan_repositories_if_enabled, write_js_routes_if_enabled, - write_metadata_if_needed, write_usage_data) + write_metadata_if_needed, write_usage_data, import_license_if_present) from rhodecode.lib.statsd_client import StatsdClient log = logging.getLogger(__name__) @@ -99,6 +99,7 @@ def make_pyramid_app(global_config, **se # Apply compatibility patches patches.inspect_getargspec() + patches.repoze_sendmail_lf_fix() load_pyramid_environment(global_config, settings) @@ -114,6 +115,9 @@ def make_pyramid_app(global_config, **se celery_settings = get_celery_config(settings) config.configure_celery(celery_settings) + # final config set... + propagate_rhodecode_config(global_config, settings, config.registry.settings) + # creating the app uses a connection - return it after we are done meta.Session.remove() @@ -396,7 +400,8 @@ def includeme(config, auth_resources=Non pyramid.events.ApplicationCreated) config.add_subscriber(write_js_routes_if_enabled, pyramid.events.ApplicationCreated) - + config.add_subscriber(import_license_if_present, + pyramid.events.ApplicationCreated) # Set the default renderer for HTML templates to mako. config.add_mako_renderer('.html') diff --git a/rhodecode/config/patches.py b/rhodecode/config/patches.py --- a/rhodecode/config/patches.py +++ b/rhodecode/config/patches.py @@ -158,3 +158,10 @@ def inspect_getargspec(): inspect.getargspec = inspect.getfullargspec return inspect + + +def repoze_sendmail_lf_fix(): + from repoze.sendmail import encoding + from email.policy import SMTP + + encoding.encode_message = lambda message, *args, **kwargs: message.as_bytes(policy=SMTP) diff --git a/rhodecode/config/utils.py b/rhodecode/config/utils.py --- a/rhodecode/config/utils.py +++ b/rhodecode/config/utils.py @@ -35,7 +35,7 @@ def configure_vcs(config): 'svn': 'rhodecode.lib.vcs.backends.svn.SubversionRepository', } - conf.settings.HOOKS_PROTOCOL = config['vcs.hooks.protocol'] + conf.settings.HOOKS_PROTOCOL = config['vcs.hooks.protocol.v2'] conf.settings.HOOKS_HOST = config['vcs.hooks.host'] conf.settings.DEFAULT_ENCODINGS = config['default_encoding'] conf.settings.ALIASES[:] = config['vcs.backends'] diff --git a/rhodecode/lib/archive_cache/__init__.py b/rhodecode/lib/archive_cache/__init__.py --- a/rhodecode/lib/archive_cache/__init__.py +++ b/rhodecode/lib/archive_cache/__init__.py @@ -31,9 +31,11 @@ cache_meta = None def includeme(config): + return # don't init cache currently for faster startup time + # init our cache at start - settings = config.get_settings() - get_archival_cache_store(settings) + # settings = config.get_settings() + # get_archival_cache_store(settings) def get_archival_config(config): diff --git a/rhodecode/lib/archive_cache/backends/objectstore_cache.py b/rhodecode/lib/archive_cache/backends/objectstore_cache.py --- a/rhodecode/lib/archive_cache/backends/objectstore_cache.py +++ b/rhodecode/lib/archive_cache/backends/objectstore_cache.py @@ -58,7 +58,7 @@ class S3Shard(BaseShard): # ensure folder in bucket exists destination = self.bucket if not self.fs.exists(destination): - self.fs.mkdir(destination, s3_additional_kwargs={}) + self.fs.mkdir(destination) writer = self._get_writer(full_path, mode) diff --git a/rhodecode/lib/celerylib/loader.py b/rhodecode/lib/celerylib/loader.py --- a/rhodecode/lib/celerylib/loader.py +++ b/rhodecode/lib/celerylib/loader.py @@ -27,10 +27,11 @@ Celery loader, run with:: --scheduler rhodecode.lib.celerylib.scheduler.RcScheduler \ --loglevel DEBUG --ini=.dev/dev.ini """ -from rhodecode.config.patches import inspect_getargspec, inspect_formatargspec -inspect_getargspec() -inspect_formatargspec() +from rhodecode.config import patches +patches.inspect_getargspec() +patches.inspect_formatargspec() # python3.11 inspect patches for backward compat on `paste` code +patches.repoze_sendmail_lf_fix() import sys import logging diff --git a/rhodecode/lib/celerylib/tasks.py b/rhodecode/lib/celerylib/tasks.py --- a/rhodecode/lib/celerylib/tasks.py +++ b/rhodecode/lib/celerylib/tasks.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012-2023 RhodeCode GmbH +# Copyright (C) 2012-2024 RhodeCode GmbH # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License, version 3 @@ -64,8 +64,9 @@ def send_email(recipients, subject, body "Make sure that `smtp_server` variable is configured " "inside the .ini file") return False - - subject = "%s %s" % (email_config.get('email_prefix', ''), subject) + conf_prefix = email_config.get('email_prefix', None) + prefix = f'{conf_prefix} ' if conf_prefix else '' + subject = f"{prefix}{subject}" if recipients: if isinstance(recipients, str): @@ -86,8 +87,8 @@ def send_email(recipients, subject, body email_conf = dict( host=mail_server, port=email_config.get('smtp_port', 25), - username=email_config.get('smtp_username'), - password=email_config.get('smtp_password'), + username=email_config.get('smtp_username', None), + password=email_config.get('smtp_password', None), tls=str2bool(email_config.get('smtp_use_tls')), ssl=str2bool(email_config.get('smtp_use_ssl')), @@ -207,7 +208,7 @@ def create_repo(form_data, cur_user): hooks_base.create_repository(created_by=owner.username, **repo.get_dict()) # update repo commit caches initially - repo.update_commit_cache() + repo.update_commit_cache(recursive=False) # set new created state repo.set_state(Repository.STATE_CREATED) @@ -298,7 +299,7 @@ def create_repo_fork(form_data, cur_user # update repo commit caches initially config = repo._config config.set('extensions', 'largefiles', '') - repo.update_commit_cache(config=config) + repo.update_commit_cache(config=config, recursive=False) # set new created state repo.set_state(Repository.STATE_CREATED) @@ -390,7 +391,7 @@ def sync_last_update_for_objects(*args, .order_by(Repository.group_id.asc()) for repo in repos: - repo.update_commit_cache() + repo.update_commit_cache(recursive=False) skip_groups = kwargs.get('skip_groups') if not skip_groups: diff --git a/rhodecode/lib/db_manage.py b/rhodecode/lib/db_manage.py --- a/rhodecode/lib/db_manage.py +++ b/rhodecode/lib/db_manage.py @@ -570,7 +570,6 @@ class DbManage(object): self.create_ui_settings(path) ui_config = [ - ('web', 'push_ssl', 'False'), ('web', 'allow_archive', 'gz zip bz2'), ('web', 'allow_push', '*'), ('web', 'baseurl', '/'), diff --git a/rhodecode/lib/dbmigrate/versions/115_version_5_1_0.py b/rhodecode/lib/dbmigrate/versions/115_version_5_1_0.py --- a/rhodecode/lib/dbmigrate/versions/115_version_5_1_0.py +++ b/rhodecode/lib/dbmigrate/versions/115_version_5_1_0.py @@ -35,16 +35,19 @@ def downgrade(migrate_engine): def fixups(models, _SESSION): + for db_repo in _SESSION.query(models.Repository).all(): - config = db_repo._config - config.set('extensions', 'largefiles', '') + try: + config = db_repo._config + config.set('extensions', 'largefiles', '') - try: - scm = db_repo.scm_instance(cache=False, config=config) + scm = db_repo.scm_instance(cache=False, config=config, vcs_full_cache=False) if scm: print(f'installing hook for repo: {db_repo}') scm.install_hooks(force=True) + del scm # force GC + del config except Exception as e: print(e) print('continue...') diff --git a/rhodecode/lib/exceptions.py b/rhodecode/lib/exceptions.py --- a/rhodecode/lib/exceptions.py +++ b/rhodecode/lib/exceptions.py @@ -80,6 +80,10 @@ class AttachedPullRequestsError(Exceptio pass +class AttachedArtifactsError(Exception): + pass + + class RepoGroupAssignmentError(Exception): pass @@ -98,6 +102,11 @@ class HTTPRequirementError(HTTPClientErr self.args = (message, ) +class ClientNotSupportedError(HTTPRequirementError): + title = explanation = 'Client Not Supported' + reason = None + + class HTTPLockedRC(HTTPClientError): """ Special Exception For locked Repos in RhodeCode, the return code can diff --git a/rhodecode/lib/helpers.py b/rhodecode/lib/helpers.py --- a/rhodecode/lib/helpers.py +++ b/rhodecode/lib/helpers.py @@ -81,7 +81,7 @@ from rhodecode.lib.action_parser import from rhodecode.lib.html_filters import sanitize_html from rhodecode.lib.pagination import Page, RepoPage, SqlPage from rhodecode.lib import ext_json -from rhodecode.lib.ext_json import json +from rhodecode.lib.ext_json import json, formatted_str_json from rhodecode.lib.str_utils import safe_bytes, convert_special_chars, base64_to_str from rhodecode.lib.utils import repo_name_slug, get_custom_lexer from rhodecode.lib.str_utils import safe_str @@ -1416,62 +1416,14 @@ class InitialsGravatar(object): return "data:image/svg+xml;base64,{}".format(img_data) -def initials_gravatar(request, email_address, first_name, last_name, size=30, store_on_disk=False): +def initials_gravatar(request, email_address, first_name, last_name, size=30): svg_type = None if email_address == User.DEFAULT_USER_EMAIL: svg_type = 'default_user' klass = InitialsGravatar(email_address, first_name, last_name, size) - - if store_on_disk: - from rhodecode.apps.file_store import utils as store_utils - from rhodecode.apps.file_store.exceptions import FileNotAllowedException, \ - FileOverSizeException - from rhodecode.model.db import Session - - image_key = md5_safe(email_address.lower() - + first_name.lower() + last_name.lower()) - - storage = store_utils.get_file_storage(request.registry.settings) - filename = '{}.svg'.format(image_key) - subdir = 'gravatars' - # since final name has a counter, we apply the 0 - uid = storage.apply_counter(0, store_utils.uid_filename(filename, randomized=False)) - store_uid = os.path.join(subdir, uid) - - db_entry = FileStore.get_by_store_uid(store_uid) - if db_entry: - return request.route_path('download_file', fid=store_uid) - - img_data = klass.get_img_data(svg_type=svg_type) - img_file = store_utils.bytes_to_file_obj(img_data) - - try: - store_uid, metadata = storage.save_file( - img_file, filename, directory=subdir, - extensions=['.svg'], randomized_name=False) - except (FileNotAllowedException, FileOverSizeException): - raise - - try: - entry = FileStore.create( - file_uid=store_uid, filename=metadata["filename"], - file_hash=metadata["sha256"], file_size=metadata["size"], - file_display_name=filename, - file_description=f'user gravatar `{safe_str(filename)}`', - hidden=True, check_acl=False, user_id=1 - ) - Session().add(entry) - Session().commit() - log.debug('Stored upload in DB as %s', entry) - except Exception: - raise - - return request.route_path('download_file', fid=store_uid) - - else: - return klass.generate_svg(svg_type=svg_type) + return klass.generate_svg(svg_type=svg_type) def gravatar_external(request, gravatar_url_tmpl, email_address, size=30): diff --git a/rhodecode/lib/hook_daemon/hook_module.py b/rhodecode/lib/hook_daemon/hook_module.py --- a/rhodecode/lib/hook_daemon/hook_module.py +++ b/rhodecode/lib/hook_daemon/hook_module.py @@ -66,12 +66,12 @@ class Hooks(object): result = hook(extras) if result is None: raise Exception(f'Failed to obtain hook result from func: {hook}') - except HTTPBranchProtected as handled_error: + except HTTPBranchProtected as error: # Those special cases don't need error reporting. It's a case of # locked repo or protected branch result = AttributeDict({ - 'status': handled_error.code, - 'output': handled_error.explanation + 'status': error.code, + 'output': error.explanation }) except (HTTPLockedRC, Exception) as error: # locked needs different handling since we need to also diff --git a/rhodecode/lib/hooks_base.py b/rhodecode/lib/hooks_base.py --- a/rhodecode/lib/hooks_base.py +++ b/rhodecode/lib/hooks_base.py @@ -30,7 +30,7 @@ from rhodecode.lib import helpers as h from rhodecode.lib import audit_logger from rhodecode.lib.utils2 import safe_str, user_agent_normalizer from rhodecode.lib.exceptions import ( - HTTPLockedRC, HTTPBranchProtected, UserCreationError) + HTTPLockedRC, HTTPBranchProtected, UserCreationError, ClientNotSupportedError) from rhodecode.model.db import Repository, User from rhodecode.lib.statsd_client import StatsdClient @@ -64,6 +64,18 @@ def is_shadow_repo(extras): return extras['is_shadow_repo'] +def check_vcs_client(extras): + """ + Checks if vcs client is allowed (Only works in enterprise edition) + """ + try: + from rc_ee.lib.security.utils import is_vcs_client_whitelisted + except ModuleNotFoundError: + is_vcs_client_whitelisted = lambda *x: True + backend = extras.get('scm') + if not is_vcs_client_whitelisted(extras.get('user_agent'), backend): + raise ClientNotSupportedError(f"Your {backend} client is forbidden") + def _get_scm_size(alias, root_path): if not alias.startswith('.'): @@ -108,6 +120,7 @@ def pre_push(extras): It bans pushing when the repository is locked. """ + check_vcs_client(extras) user = User.get_by_username(extras.username) output = '' if extras.locked_by[0] and user.user_id != int(extras.locked_by[0]): @@ -129,6 +142,8 @@ def pre_push(extras): if extras.commit_ids and extras.check_branch_perms: auth_user = user.AuthUser() repo = Repository.get_by_repo_name(extras.repository) + if not repo: + raise ValueError(f'Repo for {extras.repository} not found') affected_branches = [] if repo.repo_type == 'hg': for entry in extras.commit_ids: @@ -180,6 +195,7 @@ def pre_pull(extras): It bans pulling when the repository is locked. """ + check_vcs_client(extras) output = '' if extras.locked_by[0]: locked_by = User.get(extras.locked_by[0]).username diff --git a/rhodecode/lib/middleware/request_wrapper.py b/rhodecode/lib/middleware/request_wrapper.py --- a/rhodecode/lib/middleware/request_wrapper.py +++ b/rhodecode/lib/middleware/request_wrapper.py @@ -46,7 +46,7 @@ class RequestWrapperTween(object): def __call__(self, request): start = time.time() - log.debug('Starting request time measurement') + log.debug('Starting request processing') response = None request.req_wrapper_start = start @@ -63,7 +63,7 @@ class RequestWrapperTween(object): total = time.time() - start log.info( - 'Req[%4s] %s %s Request to %s time: %.4fs [%s], RhodeCode %s', + 'Finished request processing: req[%4s] %s %s Request to %s time: %.4fs [%s], RhodeCode %s', count, _auth_user, request.environ.get('REQUEST_METHOD'), _path, total, get_user_agent(request. environ), _ver_, extra={"time": total, "ver": _ver_, "ip": ip, diff --git a/rhodecode/lib/middleware/simplehg.py b/rhodecode/lib/middleware/simplehg.py --- a/rhodecode/lib/middleware/simplehg.py +++ b/rhodecode/lib/middleware/simplehg.py @@ -53,25 +53,31 @@ class SimpleHg(simplevcs.SimpleVCS): return repo_name.rstrip('/') _ACTION_MAPPING = { + 'between': 'pull', + 'branches': 'pull', + 'branchmap': 'pull', + 'capabilities': 'pull', 'changegroup': 'pull', 'changegroupsubset': 'pull', + 'changesetdata': 'pull', + 'clonebundles': 'pull', + 'clonebundles_manifest': 'pull', + 'debugwireargs': 'pull', + 'filedata': 'pull', 'getbundle': 'pull', - 'stream_out': 'pull', - 'listkeys': 'pull', - 'between': 'pull', - 'branchmap': 'pull', - 'branches': 'pull', - 'clonebundles': 'pull', - 'capabilities': 'pull', - 'debugwireargs': 'pull', 'heads': 'pull', - 'lookup': 'pull', 'hello': 'pull', 'known': 'pull', + 'listkeys': 'pull', + 'lookup': 'pull', + 'manifestdata': 'pull', + 'narrow_widen': 'pull', + 'protocaps': 'pull', + 'stream_out': 'pull', # largefiles + 'getlfile': 'pull', 'putlfile': 'push', - 'getlfile': 'pull', 'statlfile': 'pull', 'lheads': 'pull', diff --git a/rhodecode/lib/middleware/simplevcs.py b/rhodecode/lib/middleware/simplevcs.py --- a/rhodecode/lib/middleware/simplevcs.py +++ b/rhodecode/lib/middleware/simplevcs.py @@ -293,7 +293,7 @@ class SimpleVCS(object): def compute_perm_vcs( cache_name, plugin_id, action, user_id, repo_name, ip_addr): - log.debug('auth: calculating permission access now...') + log.debug('auth: calculating permission access now for vcs operation: %s', action) # check IP inherit = user.inherit_default_permissions ip_allowed = AuthUser.check_ip_allowed( @@ -339,21 +339,6 @@ class SimpleVCS(object): log.exception('Failed to read http scheme') return 'http' - def _check_ssl(self, environ, start_response): - """ - Checks the SSL check flag and returns False if SSL is not present - and required True otherwise - """ - org_proto = environ['wsgi._org_proto'] - # check if we have SSL required ! if not it's a bad request ! - require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl')) - if require_ssl and org_proto == 'http': - log.debug( - 'Bad request: detected protocol is `%s` and ' - 'SSL/HTTPS is required.', org_proto) - return False - return True - def _get_default_cache_ttl(self): # take AUTH_CACHE_TTL from the `rhodecode` auth plugin plugin = loadplugin('egg:rhodecode-enterprise-ce#rhodecode') @@ -373,12 +358,6 @@ class SimpleVCS(object): meta.Session.remove() def _handle_request(self, environ, start_response): - if not self._check_ssl(environ, start_response): - reason = ('SSL required, while RhodeCode was unable ' - 'to detect this as SSL request') - log.debug('User not allowed to proceed, %s', reason) - return HTTPNotAcceptable(reason)(environ, start_response) - if not self.url_repo_name: log.warning('Repository name is empty: %s', self.url_repo_name) # failed to get repo name, we fail now diff --git a/rhodecode/lib/middleware/vcs.py b/rhodecode/lib/middleware/vcs.py --- a/rhodecode/lib/middleware/vcs.py +++ b/rhodecode/lib/middleware/vcs.py @@ -159,11 +159,18 @@ def detect_vcs_request(environ, backends # favicon often requested by browsers 'favicon.ico', + # static files no detection + '_static++', + + # debug-toolbar + '_debug_toolbar++', + # e.g /_file_store/download '_file_store++', # login - "_admin/login", + f"{ADMIN_PREFIX}/login", + f"{ADMIN_PREFIX}/logout", # 2fa f"{ADMIN_PREFIX}/check_2fa", @@ -178,12 +185,6 @@ def detect_vcs_request(environ, backends # _admin/my_account is safe too f'{ADMIN_PREFIX}/my_account++', - # static files no detection - '_static++', - - # debug-toolbar - '_debug_toolbar++', - # skip ops ping, status f'{ADMIN_PREFIX}/ops/ping', f'{ADMIN_PREFIX}/ops/status', @@ -193,11 +194,14 @@ def detect_vcs_request(environ, backends '++/repo_creating_check' ] + path_info = get_path_info(environ) path_url = path_info.lstrip('/') req_method = environ.get('REQUEST_METHOD') for item in white_list: + item = item.lstrip('/') + if item.endswith('++') and path_url.startswith(item[:-2]): log.debug('path `%s` in whitelist (match:%s), skipping...', path_url, item) return handler diff --git a/rhodecode/lib/rc_cache/backends.py b/rhodecode/lib/rc_cache/backends.py --- a/rhodecode/lib/rc_cache/backends.py +++ b/rhodecode/lib/rc_cache/backends.py @@ -38,9 +38,9 @@ from dogpile.cache.backends import redis from dogpile.cache.backends.file import FileLock from dogpile.cache.util import memoized_property -from rhodecode.lib.memory_lru_dict import LRUDict, LRUDictDebug -from rhodecode.lib.str_utils import safe_bytes, safe_str -from rhodecode.lib.type_utils import str2bool +from ...lib.memory_lru_dict import LRUDict, LRUDictDebug +from ...lib.str_utils import safe_bytes, safe_str +from ...lib.type_utils import str2bool _default_max_size = 1024 @@ -198,6 +198,13 @@ class FileNamespaceBackend(PickleSeriali def get_store(self): return self.filename + def cleanup_store(self): + for ext in ("db", "dat", "pag", "dir"): + final_filename = self.filename + os.extsep + ext + if os.path.exists(final_filename): + os.remove(final_filename) + log.warning('Removed dbm file %s', final_filename) + class BaseRedisBackend(redis_backend.RedisBackend): key_prefix = '' @@ -289,7 +296,7 @@ class RedisMsgPackBackend(MsgPackSeriali def get_mutex_lock(client, lock_key, lock_timeout, auto_renewal=False): - from rhodecode.lib._vendor import redis_lock + from ...lib._vendor import redis_lock class _RedisLockWrapper: """LockWrapper for redis_lock""" diff --git a/rhodecode/lib/rc_cache/utils.py b/rhodecode/lib/rc_cache/utils.py --- a/rhodecode/lib/rc_cache/utils.py +++ b/rhodecode/lib/rc_cache/utils.py @@ -26,9 +26,9 @@ import decorator from dogpile.cache import CacheRegion import rhodecode -from rhodecode.lib.hash_utils import sha1 -from rhodecode.lib.str_utils import safe_bytes -from rhodecode.lib.type_utils import str2bool # noqa :required by imports from .utils +from ...lib.hash_utils import sha1 +from ...lib.str_utils import safe_bytes +from ...lib.type_utils import str2bool # noqa :required by imports from .utils from . import region_meta diff --git a/rhodecode/lib/rc_commands/add_artifact.py b/rhodecode/lib/rc_commands/add_artifact.py --- a/rhodecode/lib/rc_commands/add_artifact.py +++ b/rhodecode/lib/rc_commands/add_artifact.py @@ -91,15 +91,14 @@ def command(ini_path, filename, file_pat auth_user = db_user.AuthUser(ip_addr='127.0.0.1') - storage = store_utils.get_file_storage(request.registry.settings) + f_store = store_utils.get_filestore_backend(request.registry.settings) with open(file_path, 'rb') as f: click.secho(f'Adding new artifact from path: `{file_path}`', fg='green') file_data = _store_file( - storage, auth_user, filename, content=None, check_acl=True, + f_store, auth_user, filename, content=None, check_acl=True, file_obj=f, description=description, scope_repo_id=repo.repo_id) - click.secho(f'File Data: {file_data}', - fg='green') + click.secho(f'File Data: {file_data}', fg='green') diff --git a/rhodecode/lib/rc_commands/migrate_artifact.py b/rhodecode/lib/rc_commands/migrate_artifact.py new file mode 100644 --- /dev/null +++ b/rhodecode/lib/rc_commands/migrate_artifact.py @@ -0,0 +1,122 @@ +# Copyright (C) 2016-2023 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# 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 Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import sys +import logging + +import click + +from rhodecode.lib.pyramid_utils import bootstrap +from rhodecode.lib.ext_json import json +from rhodecode.model.db import FileStore +from rhodecode.apps.file_store import utils as store_utils + +log = logging.getLogger(__name__) + + +@click.command() +@click.argument('ini_path', type=click.Path(exists=True)) +@click.argument('file_uid') +@click.option( + '--source-backend-conf', + type=click.Path(exists=True, dir_okay=False, readable=True), + help='Source backend config file path in a json format' +) +@click.option( + '--dest-backend-conf', + type=click.Path(exists=True, dir_okay=False, readable=True), + help='Source backend config file path in a json format' +) +def main(ini_path, file_uid, source_backend_conf, dest_backend_conf): + return command(ini_path, file_uid, source_backend_conf, dest_backend_conf) + + +_source_settings = {} + +_dest_settings = {} + + +def command(ini_path, file_uid, source_backend_conf, dest_backend_conf): + with bootstrap(ini_path, env={'RC_CMD_SETUP_RC': '1'}) as env: + migrate_func(env, file_uid, source_backend_conf, dest_backend_conf) + + +def migrate_func(env, file_uid, source_backend_conf=None, dest_backend_conf=None): + """ + + Example usage:: + + from rhodecode.lib.rc_commands import migrate_artifact + migrate_artifact._source_settings = { + 'file_store.backend.type': 'filesystem_v1', + 'file_store.filesystem_v1.storage_path': '/var/opt/rhodecode_data/file_store', + } + migrate_artifact._dest_settings = { + 'file_store.backend.type': 'objectstore', + 'file_store.objectstore.url': 'http://s3-minio:9000', + 'file_store.objectstore.bucket': 'rhodecode-file-store', + 'file_store.objectstore.key': 's3admin', + 'file_store.objectstore.secret': 's3secret4', + 'file_store.objectstore.region': 'eu-central-1', + } + for db_obj in FileStore.query().all(): + migrate_artifact.migrate_func({}, db_obj.file_uid) + + """ + + try: + from rc_ee.api.views.store_api import _store_file + except ImportError: + click.secho('ERROR: Unable to import store_api. ' + 'store_api is only available in EE edition of RhodeCode', + fg='red') + sys.exit(-1) + + source_settings = _source_settings + if source_backend_conf: + source_settings = json.loads(open(source_backend_conf).read()) + dest_settings = _dest_settings + if dest_backend_conf: + dest_settings = json.loads(open(dest_backend_conf).read()) + + if file_uid.isnumeric(): + file_store_db_obj = FileStore().query() \ + .filter(FileStore.file_store_id == file_uid) \ + .scalar() + else: + file_store_db_obj = FileStore().query() \ + .filter(FileStore.file_uid == file_uid) \ + .scalar() + if not file_store_db_obj: + click.secho(f'ERROR: Unable to fetch artifact from database file_uid={file_uid}', + fg='red') + sys.exit(-1) + + uid_filename = file_store_db_obj.file_uid + org_filename = file_store_db_obj.file_display_name + click.secho(f'Attempting to migrate artifact {uid_filename}, filename: {org_filename}', fg='green') + + # get old version of f_store based on the data. + + origin_f_store = store_utils.get_filestore_backend(source_settings, always_init=True) + reader, metadata = origin_f_store.fetch(uid_filename) + + target_f_store = store_utils.get_filestore_backend(dest_settings, always_init=True) + target_f_store.import_to_store(reader, org_filename, uid_filename, metadata) + + click.secho(f'Migrated artifact {uid_filename}, filename: {org_filename} into {target_f_store} storage', fg='green') diff --git a/rhodecode/lib/rc_commands/setup_rc.py b/rhodecode/lib/rc_commands/setup_rc.py --- a/rhodecode/lib/rc_commands/setup_rc.py +++ b/rhodecode/lib/rc_commands/setup_rc.py @@ -108,11 +108,10 @@ def command(ini_path, force_yes, user, e dbmanage.create_permissions() dbmanage.populate_default_permissions() if apply_license_key: - try: - from rc_license.models import apply_trial_license_if_missing - apply_trial_license_if_missing(force=True) - except ImportError: - pass + from rhodecode.model.license import apply_license_from_file + license_file_path = config.get('license.import_path') + if license_file_path: + apply_license_from_file(license_file_path, force=True) Session().commit() diff --git a/rhodecode/lib/str_utils.py b/rhodecode/lib/str_utils.py --- a/rhodecode/lib/str_utils.py +++ b/rhodecode/lib/str_utils.py @@ -181,3 +181,7 @@ def splitnewlines(text: bytes): else: lines[-1] = lines[-1][:-1] return lines + + +def header_safe_str(val): + return safe_bytes(val).decode('latin-1', errors='replace') diff --git a/rhodecode/lib/system_info.py b/rhodecode/lib/system_info.py --- a/rhodecode/lib/system_info.py +++ b/rhodecode/lib/system_info.py @@ -396,17 +396,18 @@ def storage_inodes(): @register_sysinfo -def storage_archives(): +def storage_artifacts(): import rhodecode from rhodecode.lib.helpers import format_byte_size_binary from rhodecode.lib.archive_cache import get_archival_cache_store - storage_type = rhodecode.ConfigGet().get_str('archive_cache.backend.type') + backend_type = rhodecode.ConfigGet().get_str('archive_cache.backend.type') - value = dict(percent=0, used=0, total=0, items=0, path='', text='', type=storage_type) + value = dict(percent=0, used=0, total=0, items=0, path='', text='', type=backend_type) state = STATE_OK_DEFAULT try: d_cache = get_archival_cache_store(config=rhodecode.CONFIG) + backend_type = str(d_cache) total_files, total_size, _directory_stats = d_cache.get_statistics() @@ -415,7 +416,8 @@ def storage_archives(): 'used': total_size, 'total': total_size, 'items': total_files, - 'path': d_cache.storage_path + 'path': d_cache.storage_path, + 'type': backend_type }) except Exception as e: @@ -425,8 +427,44 @@ def storage_archives(): human_value = value.copy() human_value['used'] = format_byte_size_binary(value['used']) human_value['total'] = format_byte_size_binary(value['total']) - human_value['text'] = "{} ({} items)".format( - human_value['used'], value['items']) + human_value['text'] = f"{human_value['used']} ({value['items']} items)" + + return SysInfoRes(value=value, state=state, human_value=human_value) + + +@register_sysinfo +def storage_archives(): + import rhodecode + from rhodecode.lib.helpers import format_byte_size_binary + import rhodecode.apps.file_store.utils as store_utils + from rhodecode import CONFIG + + backend_type = rhodecode.ConfigGet().get_str(store_utils.config_keys.backend_type) + + value = dict(percent=0, used=0, total=0, items=0, path='', text='', type=backend_type) + state = STATE_OK_DEFAULT + try: + f_store = store_utils.get_filestore_backend(config=CONFIG) + backend_type = str(f_store) + total_files, total_size, _directory_stats = f_store.get_statistics() + + value.update({ + 'percent': 100, + 'used': total_size, + 'total': total_size, + 'items': total_files, + 'path': f_store.storage_path, + 'type': backend_type + }) + + except Exception as e: + log.exception('failed to fetch archive cache storage') + state = {'message': str(e), 'type': STATE_ERR} + + human_value = value.copy() + human_value['used'] = format_byte_size_binary(value['used']) + human_value['total'] = format_byte_size_binary(value['total']) + human_value['text'] = f"{human_value['used']} ({value['items']} items)" return SysInfoRes(value=value, state=state, human_value=human_value) @@ -798,6 +836,7 @@ def get_system_info(environ): 'storage': SysInfo(storage)(), 'storage_inodes': SysInfo(storage_inodes)(), 'storage_archive': SysInfo(storage_archives)(), + 'storage_artifacts': SysInfo(storage_artifacts)(), 'storage_gist': SysInfo(storage_gist)(), 'storage_temp': SysInfo(storage_temp)(), diff --git a/rhodecode/lib/utils.py b/rhodecode/lib/utils.py --- a/rhodecode/lib/utils.py +++ b/rhodecode/lib/utils.py @@ -42,6 +42,8 @@ from webhelpers2.text import collapse, s from mako import exceptions +from rhodecode import ConfigGet +from rhodecode.lib.exceptions import HTTPBranchProtected, HTTPLockedRC from rhodecode.lib.hash_utils import sha256_safe, md5, sha1 from rhodecode.lib.type_utils import AttributeDict from rhodecode.lib.str_utils import safe_bytes, safe_str @@ -84,8 +86,39 @@ def adopt_for_celery(func): @wraps(func) def wrapper(extras): extras = AttributeDict(extras) - # HooksResponse implements to_json method which must be used there. - return func(extras).to_json() + try: + # HooksResponse implements to_json method which must be used there. + return func(extras).to_json() + except HTTPBranchProtected as error: + # Those special cases don't need error reporting. It's a case of + # locked repo or protected branch + error_args = error.args + return { + 'status': error.code, + 'output': error.explanation, + 'exception': type(error).__name__, + 'exception_args': error_args, + 'exception_traceback': '', + } + except HTTPLockedRC as error: + # Those special cases don't need error reporting. It's a case of + # locked repo or protected branch + error_args = error.args + return { + 'status': error.code, + 'output': error.explanation, + 'exception': type(error).__name__, + 'exception_args': error_args, + 'exception_traceback': '', + } + except Exception as e: + return { + 'status': 128, + 'output': '', + 'exception': type(e).__name__, + 'exception_args': e.args, + 'exception_traceback': '', + } return wrapper @@ -361,32 +394,39 @@ ui_sections = [ 'ui', 'web', ] -def config_data_from_db(clear_session=True, repo=None): +def prepare_config_data(clear_session=True, repo=None): """ - Read the configuration data from the database and return configuration + Read the configuration data from the database, *.ini files and return configuration tuples. """ from rhodecode.model.settings import VcsSettingsModel - config = [] - sa = meta.Session() settings_model = VcsSettingsModel(repo=repo, sa=sa) ui_settings = settings_model.get_ui_settings() ui_data = [] + config = [ + ('web', 'push_ssl', 'false'), + ] for setting in ui_settings: + # Todo: remove this section once transition to *.ini files will be completed + if setting.section in ('largefiles', 'vcs_git_lfs'): + if setting.key != 'enabled': + continue if setting.active: ui_data.append((setting.section, setting.key, setting.value)) config.append(( safe_str(setting.section), safe_str(setting.key), safe_str(setting.value))) if setting.key == 'push_ssl': - # force set push_ssl requirement to False, rhodecode - # handles that + # force set push_ssl requirement to False this is deprecated, and we must force it to False config.append(( safe_str(setting.section), safe_str(setting.key), False)) + config_getter = ConfigGet() + config.append(('vcs_git_lfs', 'store_location', config_getter.get_str('vcs.git.lfs.storage_location'))) + config.append(('largefiles', 'usercache', config_getter.get_str('vcs.hg.largefiles.storage_location'))) log.debug( 'settings ui from db@repo[%s]: %s', repo, @@ -415,7 +455,7 @@ def make_db_config(clear_session=True, r Create a :class:`Config` instance based on the values in the database. """ config = Config() - config_data = config_data_from_db(clear_session=clear_session, repo=repo) + config_data = prepare_config_data(clear_session=clear_session, repo=repo) for section, option, value in config_data: config.set(section, option, value) return config @@ -582,7 +622,7 @@ def repo2db_mapper(initial_repo_list, re log.debug('Running update server info') git_repo._update_server_info(force=True) - db_repo.update_commit_cache() + db_repo.update_commit_cache(recursive=False) config = db_repo._config config.set('extensions', 'largefiles', '') diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -2568,10 +2568,10 @@ class Repository(Base, BaseModel): return commit def flush_commit_cache(self): - self.update_commit_cache(cs_cache={'raw_id':'0'}) + self.update_commit_cache(cs_cache={'raw_id': '0'}) self.update_commit_cache() - def update_commit_cache(self, cs_cache=None, config=None): + def update_commit_cache(self, cs_cache=None, config=None, recursive=True): """ Update cache of last commit for repository cache_keys should be:: @@ -2610,6 +2610,14 @@ class Repository(Base, BaseModel): if isinstance(cs_cache, BaseCommit): cs_cache = cs_cache.__json__() + def maybe_update_recursive(instance, _config, _recursive, _cs_cache, _last_change): + if _recursive: + repo_id = instance.repo_id + _cs_cache['source_repo_id'] = repo_id + for gr in instance.groups_with_parents: + gr.changeset_cache = _cs_cache + gr.updated_on = _last_change + def is_outdated(new_cs_cache): if (new_cs_cache['raw_id'] != self.changeset_cache['raw_id'] or new_cs_cache['revision'] != self.changeset_cache['revision']): @@ -2636,6 +2644,7 @@ class Repository(Base, BaseModel): self.changeset_cache = cs_cache self.updated_on = last_change Session().add(self) + maybe_update_recursive(self, config, recursive, cs_cache, last_change) Session().commit() else: @@ -2650,6 +2659,7 @@ class Repository(Base, BaseModel): self.changeset_cache = cs_cache self.updated_on = _date_latest Session().add(self) + maybe_update_recursive(self, config, recursive, cs_cache, _date_latest) Session().commit() log.debug('updated repo `%s` with new commit cache %s, and last update_date: %s', @@ -5839,8 +5849,7 @@ class FileStore(Base, BaseModel): .filter(FileStoreMetadata.file_store_meta_key == key) \ .scalar() if has_key: - msg = 'key `{}` already defined under section `{}` for this file.'\ - .format(key, section) + msg = f'key `{key}` already defined under section `{section}` for this file.' raise ArtifactMetadataDuplicate(msg, err_section=section, err_key=key) # NOTE(marcink): raises ArtifactMetadataBadValueType @@ -5939,7 +5948,7 @@ class FileStoreMetadata(Base, BaseModel) def valid_value_type(cls, value): if value.split('.')[0] not in cls.SETTINGS_TYPES: raise ArtifactMetadataBadValueType( - 'value_type must be one of %s got %s' % (cls.SETTINGS_TYPES.keys(), value)) + f'value_type must be one of {cls.SETTINGS_TYPES.keys()} got {value}') @hybrid_property def file_store_meta_section(self): diff --git a/rhodecode/model/forms.py b/rhodecode/model/forms.py --- a/rhodecode/model/forms.py +++ b/rhodecode/model/forms.py @@ -129,6 +129,20 @@ def TOTPForm(localizer, user, allow_reco return _TOTPForm +def WhitelistedVcsClientsForm(localizer): + _ = localizer + + class _WhitelistedVcsClientsForm(formencode.Schema): + regexp = r'^(?:\s*[<>=~^!]*\s*\d{1,2}\.\d{1,2}(?:\.\d{1,2})?\s*|\*)\s*(?:,\s*[<>=~^!]*\s*\d{1,2}\.\d{1,2}(?:\.\d{1,2})?\s*|\s*\*\s*)*$' + allow_extra_fields = True + filter_extra_fields = True + git = v.Regex(regexp) + hg = v.Regex(regexp) + svn = v.Regex(regexp) + + return _WhitelistedVcsClientsForm + + def UserForm(localizer, edit=False, available_languages=None, old_data=None): old_data = old_data or {} available_languages = available_languages or [] @@ -454,13 +468,6 @@ def ApplicationUiSettingsForm(localizer) _ = localizer class _ApplicationUiSettingsForm(_BaseVcsSettingsForm): - web_push_ssl = v.StringBoolean(if_missing=False) - largefiles_usercache = All( - v.ValidPath(localizer), - v.UnicodeString(strip=True, min=2, not_empty=True)) - vcs_git_lfs_store_location = All( - v.ValidPath(localizer), - v.UnicodeString(strip=True, min=2, not_empty=True)) extensions_hggit = v.StringBoolean(if_missing=False) new_svn_branch = v.ValidSvnPattern(localizer, section='vcs_svn_branch') new_svn_tag = v.ValidSvnPattern(localizer, section='vcs_svn_tag') diff --git a/rhodecode/model/license.py b/rhodecode/model/license.py new file mode 100644 --- /dev/null +++ b/rhodecode/model/license.py @@ -0,0 +1,17 @@ + +def apply_license(*args, **kwargs): + pass + +try: + from rc_license.models import apply_license +except ImportError: + pass + + +def apply_license_from_file(*args, **kwargs): + pass + +try: + from rc_license.models import apply_license_from_file +except ImportError: + pass diff --git a/rhodecode/model/notification.py b/rhodecode/model/notification.py --- a/rhodecode/model/notification.py +++ b/rhodecode/model/notification.py @@ -117,14 +117,16 @@ class NotificationModel(BaseModel): # add mentioned users into recipients final_recipients = set(recipients_objs).union(mention_recipients) - (subject, email_body, email_body_plaintext) = \ - EmailNotificationModel().render_email(notification_type, **email_kwargs) + # No need to render email if we are sending just notification + if with_email: + (subject, email_body, email_body_plaintext) = \ + EmailNotificationModel().render_email(notification_type, **email_kwargs) - if not notification_subject: - notification_subject = subject + if not notification_subject: + notification_subject = subject - if not notification_body: - notification_body = email_body_plaintext + if not notification_body: + notification_body = email_body_plaintext notification = Notification.create( created_by=created_by_obj, subject=notification_subject, diff --git a/rhodecode/model/repo.py b/rhodecode/model/repo.py --- a/rhodecode/model/repo.py +++ b/rhodecode/model/repo.py @@ -31,7 +31,7 @@ from zope.cachedescriptors.property impo from rhodecode import events from rhodecode.lib.auth import HasUserGroupPermissionAny from rhodecode.lib.caching_query import FromCache -from rhodecode.lib.exceptions import AttachedForksError, AttachedPullRequestsError +from rhodecode.lib.exceptions import AttachedForksError, AttachedPullRequestsError, AttachedArtifactsError from rhodecode.lib import hooks_base from rhodecode.lib.user_log_filter import user_log_filter from rhodecode.lib.utils import make_db_config @@ -736,7 +736,7 @@ class RepoModel(BaseModel): log.error(traceback.format_exc()) raise - def delete(self, repo, forks=None, pull_requests=None, fs_remove=True, cur_user=None): + def delete(self, repo, forks=None, pull_requests=None, artifacts=None, fs_remove=True, cur_user=None): """ Delete given repository, forks parameter defines what do do with attached forks. Throws AttachedForksError if deleted repo has attached @@ -745,6 +745,7 @@ class RepoModel(BaseModel): :param repo: :param forks: str 'delete' or 'detach' :param pull_requests: str 'delete' or None + :param artifacts: str 'delete' or None :param fs_remove: remove(archive) repo from filesystem """ if not cur_user: @@ -767,6 +768,13 @@ class RepoModel(BaseModel): if pull_requests != 'delete' and (pr_sources or pr_targets): raise AttachedPullRequestsError() + artifacts_objs = repo.artifacts + if artifacts == 'delete': + for a in artifacts_objs: + self.sa.delete(a) + elif [a for a in artifacts_objs]: + raise AttachedArtifactsError() + old_repo_dict = repo.get_dict() events.trigger(events.RepoPreDeleteEvent(repo)) try: diff --git a/rhodecode/model/settings.py b/rhodecode/model/settings.py --- a/rhodecode/model/settings.py +++ b/rhodecode/model/settings.py @@ -486,7 +486,6 @@ class VcsSettingsModel(object): ) GLOBAL_HG_SETTINGS = ( ('extensions', 'largefiles'), - ('largefiles', 'usercache'), ('phases', 'publish'), ('extensions', 'evolve'), ('extensions', 'topic'), @@ -496,12 +495,10 @@ class VcsSettingsModel(object): GLOBAL_GIT_SETTINGS = ( ('vcs_git_lfs', 'enabled'), - ('vcs_git_lfs', 'store_location') ) SVN_BRANCH_SECTION = 'vcs_svn_branch' SVN_TAG_SECTION = 'vcs_svn_tag' - SSL_SETTING = ('web', 'push_ssl') PATH_SETTING = ('paths', '/') def __init__(self, sa=None, repo=None): @@ -666,18 +663,16 @@ class VcsSettingsModel(object): self.repo_settings, *phases, value=safe_str(data[phases_key])) def create_or_update_global_hg_settings(self, data): - opts_len = 4 - largefiles, largefiles_store, phases, evolve \ + opts_len = 3 + largefiles, phases, evolve \ = self.GLOBAL_HG_SETTINGS[:opts_len] - largefiles_key, largefiles_store_key, phases_key, evolve_key \ + largefiles_key, phases_key, evolve_key \ = self._get_settings_keys(self.GLOBAL_HG_SETTINGS[:opts_len], data) self._create_or_update_ui( self.global_settings, *largefiles, value='', active=data[largefiles_key]) self._create_or_update_ui( - self.global_settings, *largefiles_store, value=data[largefiles_store_key]) - self._create_or_update_ui( self.global_settings, *phases, value=safe_str(data[phases_key])) self._create_or_update_ui( self.global_settings, *evolve, value='', @@ -697,26 +692,17 @@ class VcsSettingsModel(object): active=data[lfs_enabled_key]) def create_or_update_global_git_settings(self, data): - lfs_enabled, lfs_store_location \ - = self.GLOBAL_GIT_SETTINGS - lfs_enabled_key, lfs_store_location_key \ - = self._get_settings_keys(self.GLOBAL_GIT_SETTINGS, data) + lfs_enabled = self.GLOBAL_GIT_SETTINGS[0] + lfs_enabled_key = self._get_settings_keys(self.GLOBAL_GIT_SETTINGS, data)[0] self._create_or_update_ui( self.global_settings, *lfs_enabled, value=data[lfs_enabled_key], active=data[lfs_enabled_key]) - self._create_or_update_ui( - self.global_settings, *lfs_store_location, - value=data[lfs_store_location_key]) def create_or_update_global_svn_settings(self, data): # branch/tags patterns self._create_svn_settings(self.global_settings, data) - def update_global_ssl_setting(self, value): - self._create_or_update_ui( - self.global_settings, *self.SSL_SETTING, value=value) - @assert_repo_settings def delete_repo_svn_pattern(self, id_): ui = self.repo_settings.UiDbModel.get(id_) diff --git a/rhodecode/model/user.py b/rhodecode/model/user.py --- a/rhodecode/model/user.py +++ b/rhodecode/model/user.py @@ -557,10 +557,10 @@ class UserModel(BaseModel): elif handle_mode == 'delete': from rhodecode.apps.file_store import utils as store_utils request = get_current_request() - storage = store_utils.get_file_storage(request.registry.settings) + f_store = store_utils.get_filestore_backend(request.registry.settings) for a in artifacts: file_uid = a.file_uid - storage.delete(file_uid) + f_store.delete(file_uid) self.sa.delete(a) left_overs = False diff --git a/rhodecode/public/js/rhodecode/routes.js b/rhodecode/public/js/rhodecode/routes.js --- a/rhodecode/public/js/rhodecode/routes.js +++ b/rhodecode/public/js/rhodecode/routes.js @@ -86,6 +86,7 @@ function registerRCRoutes() { pyroutes.register('admin_settings_vcs_update', '/_admin/settings/vcs/update', []); pyroutes.register('admin_settings_visual', '/_admin/settings/visual', []); pyroutes.register('admin_settings_visual_update', '/_admin/settings/visual/update', []); + pyroutes.register('admin_security_modify_allowed_vcs_client_versions', '/_admin/security/modify/allowed_vcs_client_versions', []); pyroutes.register('apiv2', '/_admin/api', []); pyroutes.register('atom_feed_home', '/%(repo_name)s/feed-atom', ['repo_name']); pyroutes.register('atom_feed_home_old', '/%(repo_name)s/feed/atom', ['repo_name']); diff --git a/rhodecode/subscribers.py b/rhodecode/subscribers.py --- a/rhodecode/subscribers.py +++ b/rhodecode/subscribers.py @@ -111,9 +111,11 @@ def scan_repositories_if_enabled(event): This is subscribed to the `pyramid.events.ApplicationCreated` event. It does a repository scan if enabled in the settings. """ + settings = event.app.registry.settings vcs_server_enabled = settings['vcs.server.enable'] import_on_startup = settings['startup.import_repos'] + if vcs_server_enabled and import_on_startup: from rhodecode.model.scm import ScmModel from rhodecode.lib.utils import repo2db_mapper @@ -205,7 +207,7 @@ def write_usage_data(event): return def get_update_age(dest_file): - now = datetime.datetime.utcnow() + now = datetime.datetime.now(datetime.UTC) with open(dest_file, 'rb') as f: data = ext_json.json.loads(f.read()) @@ -216,10 +218,9 @@ def write_usage_data(event): return 0 - utc_date = datetime.datetime.utcnow() + utc_date = datetime.datetime.now(datetime.UTC) hour_quarter = int(math.ceil((utc_date.hour + utc_date.minute/60.0) / 6.)) - fname = '.rc_usage_{date.year}{date.month:02d}{date.day:02d}_{hour}.json'.format( - date=utc_date, hour=hour_quarter) + fname = f'.rc_usage_{utc_date.year}{utc_date.month:02d}{utc_date.day:02d}_{hour_quarter}.json' ini_loc = os.path.dirname(rhodecode.CONFIG.get('__file__')) usage_dir = os.path.join(ini_loc, '.rcusage') @@ -314,6 +315,28 @@ def write_js_routes_if_enabled(event): log.exception('Failed to write routes.js into %s', jsroutes_file_path) +def import_license_if_present(event): + """ + This is subscribed to the `pyramid.events.ApplicationCreated` event. It + does a import license key based on a presence of the file. + """ + settings = event.app.registry.settings + + rhodecode_edition_id = settings.get('rhodecode.edition_id') + license_file_path = settings.get('license.import_path') + force = settings.get('license.import_path_mode') == 'force' + + if license_file_path and rhodecode_edition_id == 'EE': + log.debug('license.import_path= is set importing license from %s', license_file_path) + from rhodecode.model.meta import Session + from rhodecode.model.license import apply_license_from_file + try: + apply_license_from_file(license_file_path, force=force) + Session().commit() + except OSError: + log.exception('Failed to import license from %s, make sure this file exists', license_file_path) + + class Subscriber(object): """ Base class for subscribers to the pyramid event system. diff --git a/rhodecode/templates/admin/auth/auth_settings.mako b/rhodecode/templates/admin/auth/auth_settings.mako --- a/rhodecode/templates/admin/auth/auth_settings.mako +++ b/rhodecode/templates/admin/auth/auth_settings.mako @@ -26,8 +26,13 @@

% endif diff --git a/rhodecode/tests/config/test_sanitize_settings.py b/rhodecode/tests/config/test_sanitize_settings.py --- a/rhodecode/tests/config/test_sanitize_settings.py +++ b/rhodecode/tests/config/test_sanitize_settings.py @@ -117,7 +117,7 @@ class TestSanitizeVcsSettings(object): _string_funcs = [ ('vcs.svn.compatible_version', ''), - ('vcs.hooks.protocol', 'http'), + ('vcs.hooks.protocol.v2', 'celery'), ('vcs.hooks.host', '*'), ('vcs.scm_app_implementation', 'http'), ('vcs.server', ''), diff --git a/rhodecode/tests/fixture.py b/rhodecode/tests/fixture.py --- a/rhodecode/tests/fixture.py +++ b/rhodecode/tests/fixture.py @@ -305,7 +305,7 @@ class Fixture(object): return r def destroy_repo(self, repo_name, **kwargs): - RepoModel().delete(repo_name, pull_requests='delete', **kwargs) + RepoModel().delete(repo_name, pull_requests='delete', artifacts='delete', **kwargs) Session().commit() def destroy_repo_on_filesystem(self, repo_name): diff --git a/rhodecode/tests/fixture_mods/fixture_pyramid.py b/rhodecode/tests/fixture_mods/fixture_pyramid.py --- a/rhodecode/tests/fixture_mods/fixture_pyramid.py +++ b/rhodecode/tests/fixture_mods/fixture_pyramid.py @@ -110,7 +110,7 @@ def ini_config(request, tmpdir_factory, 'vcs.server.protocol': 'http', 'vcs.scm_app_implementation': 'http', 'vcs.svn.proxy.enabled': 'true', - 'vcs.hooks.protocol': 'http', + 'vcs.hooks.protocol.v2': 'celery', 'vcs.hooks.host': '*', 'repo_store.path': TESTS_TMP_PATH, 'app.service_api.token': 'service_secret_token', diff --git a/rhodecode/tests/lib/middleware/test_simplehg.py b/rhodecode/tests/lib/middleware/test_simplehg.py --- a/rhodecode/tests/lib/middleware/test_simplehg.py +++ b/rhodecode/tests/lib/middleware/test_simplehg.py @@ -120,7 +120,6 @@ def test_get_config(user_util, baseapp, expected_config = [ ('vcs_svn_tag', 'ff89f8c714d135d865f44b90e5413b88de19a55f', '/tags/*'), - ('web', 'push_ssl', 'False'), ('web', 'allow_push', '*'), ('web', 'allow_archive', 'gz zip bz2'), ('web', 'baseurl', '/'), diff --git a/rhodecode/tests/lib/middleware/test_simplevcs.py b/rhodecode/tests/lib/middleware/test_simplevcs.py --- a/rhodecode/tests/lib/middleware/test_simplevcs.py +++ b/rhodecode/tests/lib/middleware/test_simplevcs.py @@ -239,7 +239,6 @@ class TestShadowRepoExposure(object): """ controller = StubVCSController( baseapp.config.get_settings(), request_stub.registry) - controller._check_ssl = mock.Mock() controller.is_shadow_repo = True controller._action = 'pull' controller._is_shadow_repo_dir = True @@ -267,7 +266,6 @@ class TestShadowRepoExposure(object): """ controller = StubVCSController( baseapp.config.get_settings(), request_stub.registry) - controller._check_ssl = mock.Mock() controller.is_shadow_repo = True controller._action = 'pull' controller._is_shadow_repo_dir = False @@ -291,7 +289,6 @@ class TestShadowRepoExposure(object): """ controller = StubVCSController( baseapp.config.get_settings(), request_stub.registry) - controller._check_ssl = mock.Mock() controller.is_shadow_repo = True controller._action = 'push' controller.stub_response_body = (b'dummy body value',) @@ -399,7 +396,7 @@ class TestGenerateVcsResponse(object): def call_controller_with_response_body(self, response_body): settings = { 'base_path': 'fake_base_path', - 'vcs.hooks.protocol': 'http', + 'vcs.hooks.protocol.v2': 'celery', 'vcs.hooks.direct_calls': False, } registry = AttributeDict() diff --git a/rhodecode/tests/lib/test_utils.py b/rhodecode/tests/lib/test_utils.py --- a/rhodecode/tests/lib/test_utils.py +++ b/rhodecode/tests/lib/test_utils.py @@ -371,7 +371,7 @@ class TestMakeDbConfig(object): ('section2', 'option2', 'value2'), ('section3', 'option3', 'value3'), ] - with mock.patch.object(utils, 'config_data_from_db') as config_mock: + with mock.patch.object(utils, 'prepare_config_data') as config_mock: config_mock.return_value = test_data kwargs = {'clear_session': False, 'repo': 'test_repo'} result = utils.make_db_config(**kwargs) @@ -381,8 +381,8 @@ class TestMakeDbConfig(object): assert value == expected_value -class TestConfigDataFromDb(object): - def test_config_data_from_db_returns_active_settings(self): +class TestPrepareConfigData(object): + def test_prepare_config_data_returns_active_settings(self): test_data = [ UiSetting('section1', 'option1', 'value1', True), UiSetting('section2', 'option2', 'value2', True), @@ -398,7 +398,7 @@ class TestConfigDataFromDb(object): instance_mock = mock.Mock() model_mock.return_value = instance_mock instance_mock.get_ui_settings.return_value = test_data - result = utils.config_data_from_db( + result = utils.prepare_config_data( clear_session=False, repo=repo_name) self._assert_repo_name_passed(model_mock, repo_name) @@ -407,7 +407,8 @@ class TestConfigDataFromDb(object): ('section1', 'option1', 'value1'), ('section2', 'option2', 'value2'), ] - assert result == expected_result + # We have extra config items returned, so we're ignoring two last items + assert result[:2] == expected_result def _assert_repo_name_passed(self, model_mock, repo_name): assert model_mock.call_count == 1 diff --git a/rhodecode/tests/models/settings/test_vcs_settings.py b/rhodecode/tests/models/settings/test_vcs_settings.py --- a/rhodecode/tests/models/settings/test_vcs_settings.py +++ b/rhodecode/tests/models/settings/test_vcs_settings.py @@ -578,21 +578,9 @@ class TestCreateOrUpdateRepoHgSettings(o assert str(exc_info.value) == 'Repository is not specified' -class TestUpdateGlobalSslSetting(object): - def test_updates_global_hg_settings(self): - model = VcsSettingsModel() - with mock.patch.object(model, '_create_or_update_ui') as create_mock: - model.update_global_ssl_setting('False') - Session().commit() - - create_mock.assert_called_once_with( - model.global_settings, 'web', 'push_ssl', value='False') - - class TestCreateOrUpdateGlobalHgSettings(object): FORM_DATA = { 'extensions_largefiles': False, - 'largefiles_usercache': '/example/largefiles-store', 'phases_publish': False, 'extensions_evolve': False } @@ -605,7 +593,6 @@ class TestCreateOrUpdateGlobalHgSettings expected_calls = [ mock.call(model.global_settings, 'extensions', 'largefiles', active=False, value=''), - mock.call(model.global_settings, 'largefiles', 'usercache', value='/example/largefiles-store'), mock.call(model.global_settings, 'phases', 'publish', value='False'), mock.call(model.global_settings, 'extensions', 'evolve', active=False, value=''), mock.call(model.global_settings, 'experimental', 'evolution', active=False, value=''), @@ -632,7 +619,6 @@ class TestCreateOrUpdateGlobalHgSettings class TestCreateOrUpdateGlobalGitSettings(object): FORM_DATA = { 'vcs_git_lfs_enabled': False, - 'vcs_git_lfs_store_location': '/example/lfs-store', } def test_creates_repo_hg_settings_when_data_is_correct(self): @@ -643,7 +629,6 @@ class TestCreateOrUpdateGlobalGitSetting expected_calls = [ mock.call(model.global_settings, 'vcs_git_lfs', 'enabled', active=False, value=False), - mock.call(model.global_settings, 'vcs_git_lfs', 'store_location', value='/example/lfs-store'), ] assert expected_calls == create_mock.call_args_list @@ -1001,9 +986,7 @@ class TestCreateOrUpdateRepoSettings(obj 'hooks_outgoing_pull_logger': False, 'extensions_largefiles': False, 'extensions_evolve': False, - 'largefiles_usercache': '/example/largefiles-store', 'vcs_git_lfs_enabled': False, - 'vcs_git_lfs_store_location': '/', 'phases_publish': 'False', 'rhodecode_pr_merge_enabled': False, 'rhodecode_use_outdated_comments': False, diff --git a/rhodecode/tests/models/test_pullrequest.py b/rhodecode/tests/models/test_pullrequest.py --- a/rhodecode/tests/models/test_pullrequest.py +++ b/rhodecode/tests/models/test_pullrequest.py @@ -449,7 +449,7 @@ class TestPullRequestModel(object): @pytest.mark.usefixtures('config_stub') class TestIntegrationMerge(object): @pytest.mark.parametrize('extra_config', ( - {'vcs.hooks.protocol': 'http', 'vcs.hooks.direct_calls': False}, + {'vcs.hooks.protocol.v2': 'celery', 'vcs.hooks.direct_calls': False}, )) def test_merge_triggers_push_hooks( self, pr_util, user_admin, capture_rcextensions, merge_extras, diff --git a/rhodecode/tests/rhodecode.ini b/rhodecode/tests/rhodecode.ini --- a/rhodecode/tests/rhodecode.ini +++ b/rhodecode/tests/rhodecode.ini @@ -36,7 +36,7 @@ port = 10020 ; GUNICORN APPLICATION SERVER ; ########################### -; run with gunicorn --paste rhodecode.ini --config gunicorn_conf.py +; run with gunicorn --config gunicorn_conf.py --paste rhodecode.ini ; Module to use, this setting shouldn't be changed use = egg:gunicorn#main @@ -249,15 +249,56 @@ labs_settings_active = true ; optional prefix to Add to email Subject #exception_tracker.email_prefix = [RHODECODE ERROR] -; File store configuration. This is used to store and serve uploaded files -file_store.enabled = true +; NOTE: this setting IS DEPRECATED: +; file_store backend is always enabled +#file_store.enabled = true +; NOTE: this setting IS DEPRECATED: +; file_store.backend = X -> use `file_store.backend.type = filesystem_v2` instead ; Storage backend, available options are: local -file_store.backend = local +#file_store.backend = local +; NOTE: this setting IS DEPRECATED: +; file_store.storage_path = X -> use `file_store.filesystem_v2.storage_path = X` instead ; path to store the uploaded binaries and artifacts -file_store.storage_path = /var/opt/rhodecode_data/file_store +#file_store.storage_path = /var/opt/rhodecode_data/file_store + +; Artifacts file-store, is used to store comment attachments and artifacts uploads. +; file_store backend type: filesystem_v1, filesystem_v2 or objectstore (s3-based) are available as options +; filesystem_v1 is backwards compat with pre 5.1 storage changes +; new installations should choose filesystem_v2 or objectstore (s3-based), pick filesystem when migrating from +; previous installations to keep the artifacts without a need of migration +file_store.backend.type = filesystem_v1 + +; filesystem options... +file_store.filesystem_v1.storage_path = /var/opt/rhodecode_data/test_artifacts_file_store + +; filesystem_v2 options... +file_store.filesystem_v2.storage_path = /var/opt/rhodecode_data/test_artifacts_file_store_2 +file_store.filesystem_v2.shards = 8 +; objectstore options... +; url for s3 compatible storage that allows to upload artifacts +; e.g http://minio:9000 +#file_store.backend.type = objectstore +file_store.objectstore.url = http://s3-minio:9000 + +; a top-level bucket to put all other shards in +; objects will be stored in rhodecode-file-store/shard-N based on the bucket_shards number +file_store.objectstore.bucket = rhodecode-file-store-tests + +; number of sharded buckets to create to distribute archives across +; default is 8 shards +file_store.objectstore.bucket_shards = 8 + +; key for s3 auth +file_store.objectstore.key = s3admin + +; secret for s3 auth +file_store.objectstore.secret = s3secret4 + +;region for s3 storage +file_store.objectstore.region = eu-central-1 ; Redis url to acquire/check generation of archives locks archive_cache.locking.url = redis://redis:6379/1 @@ -593,6 +634,7 @@ vcs.scm_app_implementation = http ; Push/Pull operations hooks protocol, available options are: ; `http` - use http-rpc backend (default) ; `celery` - use celery based hooks +#DEPRECATED:vcs.hooks.protocol = http vcs.hooks.protocol = http ; Host on which this instance is listening for hooks. vcsserver will call this host to pull/push hooks so it should be @@ -626,6 +668,10 @@ vcs.methods.cache = false ; Legacy available options are: pre-1.4-compatible, pre-1.5-compatible, pre-1.6-compatible, pre-1.8-compatible, pre-1.9-compatible #vcs.svn.compatible_version = 1.8 +; Redis connection settings for svn integrations logic +; This connection string needs to be the same on ce and vcsserver +vcs.svn.redis_conn = redis://redis:6379/0 + ; Enable SVN proxy of requests over HTTP vcs.svn.proxy.enabled = true @@ -681,7 +727,8 @@ ssh.authorized_keys_file_path = %(here)s ; RhodeCode installation directory. ; legacy: /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper ; new rewrite: /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper-v2 -ssh.wrapper_cmd = /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper +#DEPRECATED: ssh.wrapper_cmd = /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper +ssh.wrapper_cmd.v2 = /usr/local/bin/rhodecode_bin/bin/rc-ssh-wrapper-v2 ; Allow shell when executing the ssh-wrapper command ssh.wrapper_cmd_allow_shell = false diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -189,6 +189,7 @@ setup( 'rc-upgrade-db=rhodecode.lib.rc_commands.upgrade_db:main', 'rc-ishell=rhodecode.lib.rc_commands.ishell:main', 'rc-add-artifact=rhodecode.lib.rc_commands.add_artifact:main', + 'rc-migrate-artifact=rhodecode.lib.rc_commands.migrate_artifact:main', 'rc-ssh-wrapper=rhodecode.apps.ssh_support.lib.ssh_wrapper_v1:main', 'rc-ssh-wrapper-v2=rhodecode.apps.ssh_support.lib.ssh_wrapper_v2:main', ],