Skip to content

Commit

Permalink
Opt-in heartbeat support (#127)
Browse files Browse the repository at this point in the history
* Support new admin-installed machine token

* add heartbeat support

* review improvements

* test fixes

* unit test fixes for local development

* remove admin override

* use utils._read_file for anaconda cloud read
  • Loading branch information
mcg1969 authored Jan 31, 2025
1 parent b2d90c2 commit dca9551
Show file tree
Hide file tree
Showing 12 changed files with 496 additions and 146 deletions.
65 changes: 27 additions & 38 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,62 +78,50 @@ jobs:
conda-solver: classic
- name: Build test environments
run: |
source $CONDA/etc/profile.d/conda.sh
conda config --add channels defaults
conda activate base
rm -rf $CONDA/conda-bld || :
mv conda-bld $CONDA/
version=$(conda search local::anaconda-anon-usage | tail -1 | awk '{print $2}')
pkg="anaconda-anon-usage=$version"
conda install -c local anaconda-client constructor $pkg
if [[ "${{ matrix.cversion }}" == 23.7.* ]]; then
mamba=conda-libmamba-solver
echo "MAMBA=yes" >> "$GITHUB_ENV"
fi
conda create -p ./testenv -c local $pkg conda==${{ matrix.cversion }} $mamba --file tests/requirements.txt
conda create -p ./testenv -c local $pkg conda==${{ matrix.cversion }} --file tests/requirements.txt
mkdir -p ./testenv/envs
conda create -p ./testenv/envs/testchild1 python --yes
conda create -p ./testenv/envs/testchild2 python --yes
if [ -f ./testenv/Scripts/conda.exe ]; then \
sed -i.bak "s@CONDA_EXE=.*@CONDA_EXE=$PWD/testenv/Scripts/conda.exe@" testenv/etc/profile.d/conda.sh; \
fi
- name: Test environments (Windows)
if: matrix.os == 'windows-latest'
shell: cmd
run: |
call testenv\Scripts\activate
conda info 1>output.txt 2>&1 | type output.txt
find "Error loading" output.txt >nul
if %errorlevel% equ 0 exit -1
python tests\integration\test_config.py
if %errorlevel% neq 0 exit -1
if "%MAMBA%" equ "yes" (
conda config --set solver libmamba
python tests\integration\test_config.py
if %errorlevel% neq 0 exit -1
conda config --set solver classic
)
- name: Test code (Windows)
if: matrix.os == 'windows-latest'
shell: cmd
run: |
call testenv\Scripts\activate
pytest
- name: Test environments (Unix)
if: matrix.os != 'windows-latest'
- name: Test code
run: |
source ./testenv/bin/activate
source testenv/etc/profile.d/conda.sh
conda activate base
conda info 2>&1 | tee output.txt
if grep -q 'Error loading' output.txt; then exit -1; fi
pytest
python tests/integration/test_config.py
if [ "$MAMBA" = "yes" ]; then
conda config --set solver libmamba
python tests/integration/test_config.py
conda config --set solver classic
fi
- name: Test code (Unix)
if: matrix.os != 'windows-latest'
- name: Test heartbeats (pwsh)
if: matrix.os == 'windows-latest' && (matrix.cversion == '24.11.3' || matrix.cversion == '25.1.1')
shell: pwsh
run: |
.\testenv\shell\condabin\conda-hook.ps1
conda activate base
python tests\integration\test_heartbeats.py powershell
- name: Test heartbeats (cmd)
if: matrix.os == 'windows-latest' && (matrix.cversion == '24.11.3' || matrix.cversion == '25.1.1')
shell: cmd
run: |
call .\testenv\Scripts\activate.bat
if %errorlevel% neq 0 exit 1
python tests\integration\test_heartbeats.py cmd.exe
if %errorlevel% neq 0 exit 1
- name: Test heartbeats (bash)
if: matrix.os != 'windows-latest' && (matrix.cversion == '24.11.3' || matrix.cversion == '25.1.1')
run: |
source ./testenv/bin/activate
pytest
conda info
python tests/integration/test_heartbeats.py posix
- name: Build an installer
run: |
cd tests/integration
Expand All @@ -143,6 +131,7 @@ jobs:
if: matrix.os == 'windows-latest'
shell: cmd
run: |
source $CONDA/bin/activate
cd tests/integration
start /wait AIDTest-1.0-Windows-x86_64.exe /S /D=%USERPROFILE%\aidtest
call %USERPROFILE%\aidtest\Scripts\activate
Expand Down
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ repos:
rev: v2.4.1
hooks:
- id: codespell
args: [--write]
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.31.1
hooks:
Expand Down
116 changes: 116 additions & 0 deletions anaconda_anon_usage/heartbeat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""
This module implements a heartbeat function that sends a simple
HEAD request to an upstream repository. It can be configured to
trigger upon environment activation, but it is off by default.
The intended use case is for organizations to enable it through
system configuration for better usage tracking.
"""

import argparse
import os
import sys
from threading import Thread
from urllib.parse import urljoin

from conda.base.context import context
from conda.gateways.connection.session import get_session
from conda.models.channel import Channel

from . import utils

VERBOSE = False
STANDALONE = False
DRY_RUN = os.environ.get("ANACONDA_HEARTBEAT_DRY_RUN")

CLD_REPO = "https://repo.anaconda.cloud/"
ORG_REPO = "https://conda.anaconda.org/"
COM_REPO = "https://repo.anaconda.com/pkgs/"
REPOS = (CLD_REPO, COM_REPO, ORG_REPO)
HEARTBEAT_PATH = "noarch/activate-0.0.0-0.conda"


def _print(msg, *args, standalone=False, error=False):
global VERBOSE
global STANDALONE
if not (VERBOSE or utils.DEBUG or error):
return
if standalone and not STANDALONE:
return
# It is very important that these messages are printed to stderr
# when called from within the activate script. Otherwise they
# will insert themselves into the activation command set
ofile = sys.stdout if STANDALONE and not (error or utils.DEBUG) else sys.stderr
print(msg % args, file=ofile)


def _ping(session, url, wait):
try:
response = session.head(url, proxies=session.proxies)
_print("Status code (expect 404): %s", response.status_code)
except Exception as exc:
if type(exc).__name__ != "ConnectionError":
_print("Heartbeat error: %s", exc, error=True)


def attempt_heartbeat(channel=None, path=None, wait=False):
global DRY_RUN
line = "------------------------"
_print(line, standalone=True)
_print("anaconda-anon-usage heartbeat", standalone=True)
_print(line, standalone=True)

if not hasattr(context, "_aau_initialized"):
from . import patch

patch.main()

if channel and "/" in channel:
url = channel
else:
# Silences the defaults deprecation error
if not context._channels:
context._channels = ["defaults"]
urls = [u for c in context.channels for u in Channel(c).urls()]
urls.extend(u.rstrip("/") for u in context.channel_alias.urls())
for base in REPOS:
if any(u.startswith(base) for u in urls):
break
else:
_print("No valid heartbeat channel")
_print(line, standalone=True)
return
url = urljoin(base, channel or "main") + "/"
url = urljoin(url, path or HEARTBEAT_PATH)

_print("Heartbeat url: %s", url)
_print("User agent: %s", context.user_agent)
if DRY_RUN:
_print("Dry run selected, not sending heartbeat.")
else:
session = get_session(url)
t = Thread(target=_ping, args=(session, url, wait), daemon=True)
t.start()
_print("%saiting for response", "W" if wait else "Not w")
t.join(timeout=None if wait else 0.1)
_print(line, standalone=True)


def main():
global VERBOSE
global DRY_RUN
global STANDALONE
p = argparse.ArgumentParser()
p.add_argument("-c", "--channel", default=None)
p.add_argument("-p", "--path", default=None)
p.add_argument("-d", "--dry-run", action="store_true")
p.add_argument("-q", "--quiet", action="store_true")
p.add_argument("-w", "--wait", action="store_true")
args = p.parse_args()
STANDALONE = True
VERBOSE = not args.quiet
DRY_RUN = args.dry_run
attempt_heartbeat(args.channel, args.path, args.wait)


if __name__ == "__main__":
main()
74 changes: 55 additions & 19 deletions anaconda_anon_usage/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@
# needed to deploy the additional anonymous user data. It pulls
# the token management functions themselves from the api module.

import os
import re
import sys

from conda.auxlib.decorators import memoizedproperty
from conda.base.context import Context, ParameterLoader, PrimitiveParameter, context

from .tokens import has_admin_tokens, token_string
from conda.base.context import (
Context,
ParameterLoader,
PrimitiveParameter,
context,
locate_prefix_by_name,
)

from .tokens import token_string
from .utils import _debug


Expand All @@ -18,17 +25,7 @@ def _new_user_agent(ctx):
getattr(Context, "checked_prefix", None) or context.target_prefix or sys.prefix
)
try:
# If an organization token exists, it overrides the value of
# context.anaconda_anon_usage. For most users, this has no
# effect. But this does provide a system administrator the
# ability to enable telemetry without modifying a user's
# configuration by installing an organization token. The
# effect is similar to placing "anaconda_anon_usage: true"
# in /etc/conda/.condarc.
is_enabled = context.anaconda_anon_usage or has_admin_tokens()
if is_enabled and not context.anaconda_anon_usage:
_debug("system token overriding the config setting")
token = token_string(prefix, is_enabled)
token = token_string(prefix, context.anaconda_anon_usage)
if token:
result += " " + token
except Exception: # pragma: nocover
Expand All @@ -49,6 +46,23 @@ def _new_get_main_info_str(info_dict):
return Context._old_get_main_info_str(info_dict)


def _new_activate(self):
if not context.anaconda_heartbeat:
return self._old_activate()
try:
from .heartbeat import attempt_heartbeat

env = self.env_name_or_prefix
if env and os.sep not in env:
env = locate_prefix_by_name(env)
Context.checked_prefix = env or sys.prefix
attempt_heartbeat()
except Exception as exc:
_debug("Failed to attempt heartbeat: %s", exc, error=True)
finally:
return self._old_activate()


def _patch_check_prefix():
if hasattr(Context, "_old_check_prefix"):
return
Expand Down Expand Up @@ -78,25 +92,44 @@ def _patch_conda_info():
_debug("Cannot apply anaconda_anon_usage conda info patch")


def main(plugin=False):
def _patch_activate():
_debug("Applying anaconda_anon_usage activate patch")
from conda import activate

if hasattr(activate, "_Activator"):
_Activator = activate._Activator
if hasattr(_Activator, "activate"):
_Activator._old_activate = _Activator.activate
_Activator.activate = _new_activate
return
_debug("Cannot apply anaconda_anon_usage activate patch")


def main(plugin=False, command=None):
if getattr(context, "_aau_initialized", None) is not None:
_debug("anaconda_anon_usage already active")
return False
_debug("Applying anaconda_anon_usage context patch")

# conda.base.context.Context.user_agent
# Adds the ident token to the user agent string
# Adds the ident tokens to the user agent string
Context._old_user_agent = Context.user_agent
# Using a different name ensures that this is stored
# in the cache in a different place than the original
Context.user_agent = memoizedproperty(_new_user_agent)

# conda.base.context.Context
# Adds anaconda_anon_usage as a managed string config parameter
# conda.base.context.Context.anaconda_anon_usage
# Adds the anaconda_anon_usage toggle, defaulting to true
_param = ParameterLoader(PrimitiveParameter(True))
Context.anaconda_anon_usage = _param
Context.parameter_names += (_param._set_name("anaconda_anon_usage"),)

# conda.base.context.Context
# Adds the anaconda_heartbeat toggle, defaulting to false
_param = ParameterLoader(PrimitiveParameter(False))
Context.anaconda_heartbeat = _param
Context.parameter_names += (_param._set_name("anaconda_heartbeat"),)

# conda.base.context.checked_prefix
# Saves the prefix used in a conda install command
Context.checked_prefix = None
Expand All @@ -109,7 +142,10 @@ def main(plugin=False):
# The pre-command plugin avoids the circular import
# of conda.cli.install, so we can apply the patch now
_patch_conda_info()
_patch_check_prefix()
if command == "activate":
_patch_activate()
if command == "info":
_patch_check_prefix()
else:
# We need to delay further. Schedule the patch for the
# next time context.__init__ is called.
Expand Down
3 changes: 2 additions & 1 deletion anaconda_anon_usage/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ def pre_command_patcher(command):
try:
from . import patch # noqa

patch.main(plugin=True)
patch.main(plugin=True, command=command)
except Exception as exc: # pragma: nocover
print("Error loading anaconda-anon-usage:", exc)

Expand All @@ -23,5 +23,6 @@ def conda_pre_commands():
"uninstall",
"env_create",
"search",
"activate",
}, # which else?
)
Loading

0 comments on commit dca9551

Please sign in to comment.