From 1fd322515c55d9af3afd1cf2b5e57a9f8c8d711e Mon Sep 17 00:00:00 2001 From: vsoch Date: Fri, 10 May 2024 23:00:50 -0600 Subject: [PATCH 1/3] feat: separate auth into backends Problem: we have a lot of trouble with custom auth needs. There is not one solution that works for everyone. Solution: start a design that separates auth into modules. Likely this will need a lot of testing, but this should be a start. Signed-off-by: vsoch --- .github/workflows/main.yaml | 2 +- .pre-commit-config.yaml | 5 - CHANGELOG.md | 1 + docs/getting_started/developer-guide.md | 5 + docs/getting_started/user-guide.md | 77 ++-- oras/auth/__init__.py | 17 + oras/auth/base.py | 156 ++++++++ oras/auth/basic.py | 42 +++ oras/auth/token.py | 164 ++++++++ oras/{auth.py => auth/utils.py} | 0 oras/client.py | 243 +----------- oras/defaults.py | 1 - oras/main/login.py | 6 +- oras/oci.py | 8 + oras/provider.py | 478 +++++++++--------------- oras/tests/test_oras.py | 1 + oras/types.py | 10 + oras/version.py | 2 +- scripts/test.sh | 2 +- 19 files changed, 612 insertions(+), 608 deletions(-) create mode 100644 oras/auth/__init__.py create mode 100644 oras/auth/base.py create mode 100644 oras/auth/basic.py create mode 100644 oras/auth/token.py rename oras/{auth.py => auth/utils.py} (100%) create mode 100644 oras/types.py diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ebfbc89..f6e7e5f 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -32,7 +32,7 @@ jobs: ports: - 5000:5000 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ce2acd..fde1980 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,8 +22,3 @@ repos: rev: 6.1.0 hooks: - id: flake8 - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.5.1 - hooks: - - id: mypy - additional_dependencies: ["types-requests"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 28936e6..dfa1b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and **Merged pull requests**. Critical items to know are: The versions coincide with releases on pip. Only major versions will be released as tags on Github. ## [0.0.x](https://github.com/oras-project/oras-py/tree/main) (0.0.x) + - refactor of auth to be provided by backend modules (0.2.0) - add option to not refresh headers during the pushing flow, useful for push with basic auth (0.1.29) - enable additionalProperties in schema validation (0.1.28) - Introduce the option to not refresh headers when fetching manifests when pulling artifacts (0.1.27) diff --git a/docs/getting_started/developer-guide.md b/docs/getting_started/developer-guide.md index 3caff55..06e4328 100644 --- a/docs/getting_started/developer-guide.md +++ b/docs/getting_started/developer-guide.md @@ -21,6 +21,11 @@ We recommend a local registry without auth for tests. $ docker run -it --rm -p 5000:5000 ghcr.io/oras-project/registry:latest ``` +Zot is a good solution too: + +```bash +``` + And then when you run `make test`, the tests will run. This ultimately runs the file [scripts/test.sh](https://github.com/oras-project/oras-py/blob/main/scripts/test.sh). If you want to test interactively, add an IPython import statement somewhere in the tests: diff --git a/docs/getting_started/user-guide.md b/docs/getting_started/user-guide.md index cf52fed..1921056 100644 --- a/docs/getting_started/user-guide.md +++ b/docs/getting_started/user-guide.md @@ -29,7 +29,7 @@ $ docker run -it --rm -p 5000:5000 ghcr.io/oras-project/registry:latest ``` And add the `-d` for detached. If you are brave and want to try basic auth: - +bash ```bash # This is an htpassword file, "b" means bcrypt htpasswd -cB -b auth.htpasswd myuser mypass @@ -67,9 +67,8 @@ class MyProvider(oras.client.OrasClient): pass ``` -If you don't use this class, it's recommended to set basic auth with -`self.set_basic_auth(username, password)`, which is provided by `oras.provider.Registry`. -Also note that we currently just have one provider type (the `Registry`) and if you +Authentication is provided by custom modules, and you can read about loading +and using them [here](#authentication). Also note that we currently just have one provider type (the `Registry`) and if you have an idea for a request or custom provider, please [let us know](https://github.com/oras-project/oras-py/issues). ### Creating OCI Objects @@ -204,7 +203,7 @@ You can read more about how registries provide tags [at the distribution spec](h ### Push Interactions Let's start with a very basic push interaction, and this one -follows [the example here](https://oras.land/cli/1_pushing/). +follows [the example here](https://oras.land/docs/how_to_guides/pushing_and_pulling/#pushing-artifacts-with-single-file).
@@ -369,14 +368,11 @@ def get_oras_client(): """ Consistent method to get an oras client """ - user = os.environ.get("ORAS_USER") - password = os.environ.get("ORAS_PASS") reg = Registry() - if user and password: - print("Found username and password for basic auth") - reg.set_basic_auth(user, password) - else: + if not reg.auth.username or not reg.auth.password: sys.exit("ORAS_USER or ORAS_PASS is missing, and required.") + + print("Found username and password for basic auth") return reg @@ -600,72 +596,49 @@ is exposed as above. For earlier versions, you can use `self._download_blob`. ### Authentication -Here is a very basic example of logging in and out of a registry using the default (basic) -provided client: - -
- -Example using basic auth (click to expand) +As of oras Python 0.2.0, authentication is handled with modules. We take this approach because +different registries have subtle different logic and it led to many errors. +By default, you will get a bearer token setup that takes an initial set of basic credentials and then makes +requests for tokens. This is set by way of defining the "auth_backend" variable. For example, +here is asking for the default. ```python import oras.client -client = oras.client.OrasClient() +client = oras.client.OrasClient(auth_backend="token") client.login(password="myuser", username="myuser", insecure=True) ``` -And logout! +If you wanted to always maintain the basic auth you might do: ```python -client.logout("localhost:5000") +import oras.client +client = oras.client.OrasClient(auth_backend="basic") +client.login(password="myuser", username="myuser", insecure=True) ``` -
- -Here is an example of getting a GitHub user and token from the environment, and -then doing a pull. +Here is a very basic example of logging in and out of a registry using the default (basic) +provided client:
-Example setting and using GitHub credentials (click to expand) +Example using basic auth (click to expand) -Given that you are pushing to GitHub packages (which has support for ORAS) -and perhaps are running in a GitHub action, you might want to get these credentials from the environment: ```python -# We will need GitHub personal access token or token -token = os.environ.get("GITHUB_TOKEN") -password = os.environ.get("GITHUB_USER") - -if not password or not user: - sys.exit("GITHUB_TOKEN and GITHUB_USER are required in the environment.") +import oras.client +client = oras.client.OrasClient(hostname="ghcr.io") +client.login(password="myuser", username="myuser") ``` -Then you can run your custom functions that use these user and password credentials, -either inspecting a particular unique resource identifier or using -your lookup of archives (paths and media types) to push: +And logout! ```python -# Pull Example -reg = MyProvider() -reg.set_basic_auth(user, password) -reg.inspect("ghcr.io/wolfv/conda-forge/linux-64/xtensor:0.9.0-0") - -# Push Example -reg = Registry() -reg.set_basic_auth(user, token) -archives = { - "/tmp/pakages-tmp.q6amnrkq/pakages-0.0.16.tar.gz": "application/vnd.oci.image.layer.v1.tar+gzip", - "/tmp/pakages-tmp.q6amnrkq/sbom.json": "application/vnd.cyclonedx"} -reg.push("ghcr.io/vsoch/excellent-dinosaur:latest", archives) +client.logout("localhost:5000") ```
-The above examples supplement our official [examples folder](https://github.com/oras-project/oras-py/tree/main/examples). -Please let us know if you need an additional example or help with your client! - - ### Debugging > Can I see more debug information? diff --git a/oras/auth/__init__.py b/oras/auth/__init__.py new file mode 100644 index 0000000..394efd0 --- /dev/null +++ b/oras/auth/__init__.py @@ -0,0 +1,17 @@ +import requests + +from oras.logger import logger + +from .basic import BasicAuth +from .token import TokenAuth + +auth_backends = {"token": TokenAuth, "basic": BasicAuth} + + +def get_auth_backend(name="token", session=None, **kwargs): + backend = auth_backends.get(name) + if not backend: + logger.exit(f"Authentication backend {backend} is not known.") + backend = backend(**kwargs) + backend.session = session or requests.Session() + return backend diff --git a/oras/auth/base.py b/oras/auth/base.py new file mode 100644 index 0000000..37098e5 --- /dev/null +++ b/oras/auth/base.py @@ -0,0 +1,156 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + + +from typing import Optional + +import oras.auth.utils as auth_utils +import oras.container +import oras.decorator as decorator +from oras.logger import logger +from oras.types import container_type + + +class AuthBackend: + """ + Generic (and default) auth backend. + """ + + def __init__(self, *args, **kwargs): + self._auths: dict = {} + + def get_auth_header(self): + raise NotImplementedError + + def get_container(self, name: container_type) -> oras.container.Container: + """ + Courtesy function to get a container from a URI. + + :param name: unique resource identifier to parse + :type name: oras.container.Container or str + """ + if isinstance(name, oras.container.Container): + return name + return oras.container.Container(name, registry=self.hostname) + + def logout(self, hostname: str): + """ + If auths are loaded, remove a hostname. + + :param hostname: the registry hostname to remove + :type hostname: str + """ + self._logout() + if not self._auths: + logger.info(f"You are not logged in to {hostname}") + return + + for host in oras.utils.iter_localhosts(hostname): + if host in self._auths: + del self._auths[host] + logger.info(f"You have successfully logged out of {hostname}") + return + logger.info(f"You are not logged in to {hostname}") + + def _logout(self): + pass + + def _load_auth(self, hostname: str) -> bool: + """ + Look for and load a named authentication token. + + :param hostname: the registry hostname to look for + :type hostname: str + """ + # Note that the hostname can be defined without a token + if hostname in self._auths: + auth = self._auths[hostname].get("auth") + + # Case 1: they use a credsStore we don't know how to read + if not auth and "credsStore" in self._auths[hostname]: + logger.warning( + '"credsStore" found in your ~/.docker/config.json, which is not supported by oras-py. Remove it, docker login, and try again.' + ) + return False + + # Case 2: no auth there (wonky file) + elif not auth: + return False + self._basic_auth = auth + return True + return False + + @decorator.ensure_container + def load_configs(self, container: container_type, configs: Optional[list] = None): + """ + Load configs to discover credentials for a specific container. + + This is typically just called once. We always add the default Docker + config to the set.s + + :param container: the parsed container URI with components + :type container: oras.container.Container + :param configs: list of configs to read (optional) + :type configs: list + """ + if not self._auths: + self._auths = auth_utils.load_configs(configs) + for registry in oras.utils.iter_localhosts(container.registry): # type: ignore + if self._load_auth(registry): + return + + def set_token_auth(self, token: str): + """ + Set token authentication. + + :param token: the bearer token + :type token: str + """ + self.token = token + self.set_header("Authorization", "Bearer %s" % token) + + def set_basic_auth(self, username: str, password: str): + """ + Set basic authentication. + + :param username: the user account name + :type username: str + :param password: the user account password + :type password: str + """ + self._basic_auth = auth_utils.get_basic_auth(username, password) + + def request_anonymous_token(self, h: auth_utils.authHeader, headers: dict) -> bool: + """ + Given no basic auth, fall back to trying to request an anonymous token. + + Returns: boolean if headers have been updated with token. + """ + if not h.realm: + logger.debug("Request anonymous token: no realm provided, exiting early") + return headers, False + + params = {} + if h.service: + params["service"] = h.service + if h.scope: + params["scope"] = h.scope + + logger.debug(f"Final params are {params}") + response = self.session.request("GET", h.realm, params=params) + if response.status_code != 200: + logger.debug(f"Response for anon token failed: {response.text}") + return headers, False + + # From https://docs.docker.com/registry/spec/auth/token/ section + # We can get token OR access_token OR both (when both they are identical) + data = response.json() + token = data.get("token") or data.get("access_token") + + # Update the headers but not self.token (expects Basic) + if token: + headers["Authorization"] = {"Authorization": "Bearer %s" % token} + + logger.debug("Warning: no token or access_token present in response.") + return headers, False diff --git a/oras/auth/basic.py b/oras/auth/basic.py new file mode 100644 index 0000000..686eeea --- /dev/null +++ b/oras/auth/basic.py @@ -0,0 +1,42 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import os + +import requests + +from .base import AuthBackend + + +class BasicAuth(AuthBackend): + """ + Generic (and default) auth backend. + """ + + def __init__(self): + username = os.environ.get("ORAS_USER") + password = os.environ.get("ORAS_PASS") + super().__init__() + if username and password: + self.set_basic_auth(username, password) + + def _logout(self): + self._basic_auth = None + + def get_auth_header(self): + return {"Authorization": "Basic %s" % self._basic_auth} + + def authenticate_request( + self, original: requests.Response, headers: dict, refresh=False + ): + """ + Authenticate Request + Given a response, look for a Www-Authenticate header to parse. + + We return True/False to indicate if the request should be retried. + + :param originalResponse: original response to get the Www-Authenticate header + :type originalResponse: requests.Response + """ + return self.get_auth_header(), True diff --git a/oras/auth/token.py b/oras/auth/token.py new file mode 100644 index 0000000..cdb8fd0 --- /dev/null +++ b/oras/auth/token.py @@ -0,0 +1,164 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +import requests + +import oras.auth.utils as auth_utils +from oras.logger import logger + +from .base import AuthBackend + + +class TokenAuth(AuthBackend): + """ + Token (OAuth2) style auth. + """ + + def __init__(self): + self.token = None + super().__init__() + + def _logout(self): + self.token = None + + def set_token_auth(self, token: str): + """ + Set token authentication. + + :param token: the bearer token + :type token: str + """ + self.token = token + + def get_auth_header(self): + return {"Authorization": "Bearer %s" % self.token} + + def reset_basic_auth(self): + """ + Given we have basic auth, reset it. + """ + if "Authorization" in self.headers: + del self.headers["Authorization"] + if self._basic_auth: + self.set_header("Authorization", "Basic %s" % self._basic_auth) + + def authenticate_request( + self, original: requests.Response, headers: dict, refresh=False + ): + """ + Authenticate Request + Given a response, look for a Www-Authenticate header to parse. + + We return True/False to indicate if the request should be retried. + + :param original: original response to get the Www-Authenticate header + :type original: requests.Response + """ + if refresh: + self.token = None + + authHeaderRaw = original.headers.get("Www-Authenticate") + if not authHeaderRaw: + logger.debug( + "Www-Authenticate not found in original response, cannot authenticate." + ) + return headers, False + + # If we have a token, set auth header (base64 encoded user/pass) + if self.token: + headers["Authorization"] = "Bearer %s" % self.token + return headers, True + + h = auth_utils.parse_auth_header(authHeaderRaw) + + # First try to request an anonymous token + logger.debug("No Authorization, requesting anonymous token") + anon_token = self.request_anonymous_token(h) + if anon_token: + logger.debug("Successfully obtained anonymous token!") + self.token = anon_token + headers["Authorization"] = "Bearer %s" % self.token + return headers, True + + # Next try for logged in token + token = self.request_token(h) + if token: + self.token = token + headers["Authorization"] = "Bearer %s" % self.token + return headers, True + + logger.error( + "This endpoint requires a token. Please use " + "basic auth with a username or password." + ) + return headers, False + + def request_token(self, h: auth_utils.authHeader) -> bool: + """ + Request an authenticated token and save for later.s + """ + params = {} + headers = {} + + # Prepare request to retry + if h.service: + logger.debug(f"Service: {h.service}") + params["service"] = h.service + headers.update( + { + "Service": h.service, + "Accept": "application/json", + "User-Agent": "oras-py", + } + ) + + # Ensure the realm starts with http + if not h.realm.startswith("http"): # type: ignore + h.realm = f"{self.prefix}://{h.realm}" + + # If the www-authenticate included a scope, honor it! + if h.scope: + logger.debug(f"Scope: {h.scope}") + params["scope"] = h.scope + + authResponse = self.session.get(h.realm, headers=headers, params=params) # type: ignore + if authResponse.status_code != 200: + logger.debug(f"Auth response was not successful: {authResponse.text}") + return + + # Request the token + info = authResponse.json() + return info.get("token") or info.get("access_token") + + def request_anonymous_token(self, h: auth_utils.authHeader) -> bool: + """ + Given no basic auth, fall back to trying to request an anonymous token. + + Returns: boolean if headers have been updated with token. + """ + if not h.realm: + logger.debug("Request anonymous token: no realm provided, exiting early") + return + + params = {} + if h.service: + params["service"] = h.service + if h.scope: + params["scope"] = h.scope + + logger.debug(f"Final params are {params}") + response = self.session.request("GET", h.realm, params=params) + if response.status_code != 200: + logger.debug(f"Response for anon token failed: {response.text}") + return + + # From https://docs.docker.com/registry/spec/auth/token/ section + # We can get token OR access_token OR both (when both they are identical) + data = response.json() + token = data.get("token") or data.get("access_token") + + # Update the headers but not self.token (expects Basic) + if token: + return token + logger.debug("Warning: no token or access_token present in response.") diff --git a/oras/auth.py b/oras/auth/utils.py similarity index 100% rename from oras/auth.py rename to oras/auth/utils.py diff --git a/oras/client.py b/oras/client.py index b788ef2..48873f2 100644 --- a/oras/client.py +++ b/oras/client.py @@ -3,244 +3,5 @@ __license__ = "Apache-2.0" -import sys -from typing import List, Optional, Union - -import oras.auth -import oras.container -import oras.main.login as login -import oras.provider -import oras.utils -import oras.version - - -class OrasClient: - """ - Create an OCI Registry as Storage (ORAS) Client. - - This is intended for controlled interactions. The user of oras-py can use - this client, the terminal command line wrappers, or the functions in main - in isolation as an internal Python API. The user can provide a custom - registry as a parameter, if desired. If not provided we default to standard - oras. - """ - - def __init__( - self, - hostname: Optional[str] = None, - registry: Optional[oras.provider.Registry] = None, - insecure: bool = False, - tls_verify: bool = True, - ): - """ - Create an ORAS client. - - The hostname is the remote registry to ping. - - :param hostname: the hostname of the registry to ping - :type hostname: str - :param registry: if provided, use this custom provider instead of default - :type registry: oras.provider.Registry or None - :param insecure: use http instead of https - :type insecure: bool - """ - self.remote = registry or oras.provider.Registry(hostname, insecure, tls_verify) - - def __repr__(self) -> str: - return str(self) - - def __str__(self) -> str: - return "[oras-client]" - - def set_token_auth(self, token: str): - """ - Set token authentication. - - :param token: the bearer token - :type token: str - """ - self.remote.set_token_auth(token) - - def set_basic_auth(self, username: str, password: str): - """ - Add basic authentication to the request. - - :param username: the user account name - :type username: str - :param password: the user account password - :type password: str - """ - self.remote.set_basic_auth(username, password) - - def version(self, return_items: bool = False) -> Union[dict, str]: - """ - Get the version of the client. - - :param return_items : return the dict of version info instead of string - :type return_items: bool - """ - version = oras.version.__version__ - - python_version = "%s.%s.%s" % ( - sys.version_info.major, - sys.version_info.minor, - sys.version_info.micro, - ) - versions = {"Version": version, "Python version": python_version} - - # If the user wants the dictionary of items returned - if return_items: - return versions - - # Otherwise return a string that can be printed - return "\n".join(["%s: %s" % (k, v) for k, v in versions.items()]) - - def get_tags(self, name: str, N=None) -> List[str]: - """ - Retrieve tags for a package. - - :param name: container URI to parse - :type name: str - :param N: number of tags (None to get all tags) - :type N: int - """ - return self.remote.get_tags(name, N=N) - - def delete_tags(self, name: str, tags=Union[str, list]) -> List[str]: - """ - Delete one or more tags for a unique resource identifier. - - Returns those successfully deleted. - - :param name: container URI to parse - :type name: str - :param tags: single or multiple tags name to delete - :type N: string or list - """ - if isinstance(tags, str): - tags = [tags] - deleted = [] - for tag in tags: - if self.remote.delete_tag(name, tag): - deleted.append(tag) - return deleted - - def push(self, *args, **kwargs): - """ - Push a container to the remote. - """ - return self.remote.push(*args, **kwargs) - - def pull(self, *args, **kwargs): - """ - Pull a container from the remote. - """ - return self.remote.pull(*args, **kwargs) - - def login( - self, - username: str, - password: str, - password_stdin: bool = False, - insecure: bool = False, - tls_verify: bool = True, - hostname: Optional[str] = None, - config_path: Optional[List[str]] = None, - ) -> dict: - """ - Login to a registry. - - :param registry: if provided, use this custom provider instead of default - :type registry: oras.provider.Registry or None - :param username: the user account name - :type username: str - :param password: the user account password - :type password: str - :param password_stdin: get the password from standard input - :type password_stdin: bool - :param insecure: use http instead of https - :type insecure: bool - :param tls_verify: verify tls - :type tls_verify: bool - :param hostname: the hostname to login to - :type hostname: str - :param config_path: list of config paths to add - :type config_path: list - """ - login_func = self._login - if hasattr(self.remote, "login"): - login_func = self.remote.login # type: ignore - return login_func( - username=username, - password=password, - password_stdin=password_stdin, - tls_verify=tls_verify, - hostname=hostname, - config_path=config_path, # type: ignore - ) - - def logout(self, hostname: str): - """ - Logout from a registry, meaning removing any auth (if loaded) - - :param hostname: the hostname to login to - :type hostname: str - """ - self.remote.logout(hostname) - - def _login( - self, - username: Optional[str] = None, - password: Optional[str] = None, - password_stdin: bool = False, - tls_verify: bool = True, - hostname: Optional[str] = None, - config_path: Optional[str] = None, - ) -> dict: - """ - Login to an OCI registry. - - The username and password can come from stdin. Most people use username - password to get a token, so we are disabling providing just a token for - now. A tool that wants to provide a token should use set_token_auth. - """ - # Read password from stdin - if password_stdin: - password = oras.utils.readline() - - # No username, try to get from stdin - if not username: - username = input("Username: ") - - # No password provided - if not password: - password = input("Password: ") - if not password: - raise ValueError("password required") - - # Cut out early if we didn't get what we need - if not password or not username: - return {"Login": "Not successful"} - - # Set basic auth for the client - self.set_basic_auth(username, password) - - # Login - # https://docker-py.readthedocs.io/en/stable/client.html?highlight=login#docker.client.DockerClient.login - try: - client = oras.utils.get_docker_client(tls_verify=tls_verify) - return client.login( - username=username, - password=password, - registry=hostname, - dockercfg_path=config_path, - ) - - # Fallback to manual login - except Exception: - return login.DockerClient().login( - username=username, # type: ignore - password=password, # type: ignore - registry=hostname, # type: ignore - dockercfg_path=config_path, - ) +# Fallback support so OrasClient still works +from .provider import Registry as OrasClient # noqa diff --git a/oras/defaults.py b/oras/defaults.py index 2992290..4082b0c 100644 --- a/oras/defaults.py +++ b/oras/defaults.py @@ -2,7 +2,6 @@ __copyright__ = "Copyright The ORAS Authors" __license__ = "Apache-2.0" - # Default tag to use default_tag = "latest" diff --git a/oras/main/login.py b/oras/main/login.py index 9b5e493..df1fac9 100644 --- a/oras/main/login.py +++ b/oras/main/login.py @@ -5,7 +5,7 @@ import os from typing import Optional -import oras.auth +import oras.auth.utils as auth_utils import oras.utils @@ -41,12 +41,12 @@ def login( oras.utils.mkdir_p(os.path.dirname(dockercfg_path)) # type: ignore cfg = {"auths": {}} if registry in cfg["auths"]: - cfg["auths"][registry]["auth"] = oras.auth.get_basic_auth( + cfg["auths"][registry]["auth"] = auth_utils.get_basic_auth( username, password ) else: cfg["auths"][registry] = { - "auth": oras.auth.get_basic_auth(username, password) + "auth": auth_utils.get_basic_auth(username, password) } oras.utils.write_json(cfg, dockercfg_path) # type: ignore return {"Status": "Login Succeeded"} diff --git a/oras/oci.py b/oras/oci.py index 4265f7d..1dbd649 100644 --- a/oras/oci.py +++ b/oras/oci.py @@ -4,6 +4,7 @@ import copy import os +from dataclasses import dataclass from typing import Dict, Optional, Tuple import jsonschema @@ -21,6 +22,13 @@ } +@dataclass +class Subject: + mediaType: str + digest: str + size: int + + class Annotations: """ Create a new set of annotations diff --git a/oras/provider.py b/oras/provider.py index 17e93f9..7c16150 100644 --- a/oras/provider.py +++ b/oras/provider.py @@ -4,9 +4,10 @@ import copy import os +import sys import urllib from contextlib import contextmanager, nullcontext -from dataclasses import asdict, dataclass +from dataclasses import asdict from http.cookiejar import DefaultCookiePolicy from tempfile import TemporaryDirectory from typing import Callable, Generator, List, Optional, Tuple, Union @@ -17,15 +18,15 @@ import oras.auth import oras.container import oras.decorator as decorator +import oras.defaults +import oras.main.login as login import oras.oci import oras.schemas import oras.utils from oras.logger import logger +from oras.types import container_type from oras.utils.fileio import PathAndOptionalContent -# container type can be string or container -container_type = Union[str, oras.container.Container] - @contextmanager def temporary_empty_config() -> Generator[str, None, None]: @@ -35,13 +36,6 @@ def temporary_empty_config() -> Generator[str, None, None]: yield config_file -@dataclass -class Subject: - mediaType: str - digest: str - size: int - - class Registry: """ Direct interactions with an OCI registry. @@ -55,24 +49,24 @@ def __init__( hostname: Optional[str] = None, insecure: bool = False, tls_verify: bool = True, + auth_backend: str = "token", ): """ - Create a new registry provider. + Create an ORAS client. + + The hostname is the remote registry to ping. - :param hostname: the registry hostname (optional) + :param hostname: the hostname of the registry to ping :type hostname: str + :param registry: if provided, use this custom provider instead of default + :type registry: oras.provider.Registry or None :param insecure: use http instead of https :type insecure: bool - :param tls_verify: verify TLS certificates - :type tls_verify: bool """ self.hostname: Optional[str] = hostname self.headers: dict = {} self.session: requests.Session = requests.Session() self.prefix: str = "http" if insecure else "https" - self.token: Optional[str] = None - self._auths: dict = {} - self._basic_auth = None self._tls_verify = tls_verify if not tls_verify: @@ -83,102 +77,133 @@ def __init__( # trying to set further CSRF cookies (Harbor is such a case) self.session.cookies.set_policy(DefaultCookiePolicy(allowed_domains=[])) - def logout(self, hostname: str): + # Get custom backend, pass on session to share + self.auth = oras.auth.get_auth_backend(auth_backend, self.session) + + def __repr__(self) -> str: + return str(self) + + def __str__(self) -> str: + return "[oras-client]" + + def version(self, return_items: bool = False) -> Union[dict, str]: """ - If auths are loaded, remove a hostname. + Get the version of the client. - :param hostname: the registry hostname to remove - :type hostname: str + :param return_items : return the dict of version info instead of string + :type return_items: bool """ - # Remove any basic auth or token - self._basic_auth = None - self.token = None + version = oras.version.__version__ - if not self._auths: - logger.info(f"You are not logged in to {hostname}") - return + python_version = "%s.%s.%s" % ( + sys.version_info.major, + sys.version_info.minor, + sys.version_info.micro, + ) + versions = {"Version": version, "Python version": python_version} - for host in oras.utils.iter_localhosts(hostname): - if host in self._auths: - del self._auths[host] - logger.info(f"You have successfully logged out of {hostname}") - return - logger.info(f"You are not logged in to {hostname}") + # If the user wants the dictionary of items returned + if return_items: + return versions - @decorator.ensure_container - def load_configs(self, container: container_type, configs: Optional[list] = None): + # Otherwise return a string that can be printed + return "\n".join(["%s: %s" % (k, v) for k, v in versions.items()]) + + def delete_tags(self, name: str, tags=Union[str, list]) -> List[str]: """ - Load configs to discover credentials for a specific container. + Delete one or more tags for a unique resource identifier. - This is typically just called once. We always add the default Docker - config to the set.s + Returns those successfully deleted. - :param container: the parsed container URI with components - :type container: oras.container.Container - :param configs: list of configs to read (optional) - :type configs: list + :param name: container URI to parse + :type name: str + :param tags: single or multiple tags name to delete + :type N: string or list """ - if not self._auths: - self._auths = oras.auth.load_configs(configs) - for registry in oras.utils.iter_localhosts(container.registry): # type: ignore - if self._load_auth(registry): - return + if isinstance(tags, str): + tags = [tags] + deleted = [] + for tag in tags: + if self.delete_tag(name, tag): + deleted.append(tag) + return deleted - def _load_auth(self, hostname: str) -> bool: + def logout(self, hostname: str): """ - Look for and load a named authentication token. + If auths are loaded, remove a hostname. - :param hostname: the registry hostname to look for + :param hostname: the registry hostname to remove :type hostname: str """ - # Note that the hostname can be defined without a token - if hostname in self._auths: - auth = self._auths[hostname].get("auth") + self.auth.logout(hostname) - # Case 1: they use a credsStore we don't know how to read - if not auth and "credsStore" in self._auths[hostname]: - logger.warning( - '"credsStore" found in your ~/.docker/config.json, which is not supported by oras-py. Remove it, docker login, and try again.' - ) - return False - - # Case 2: no auth there (wonky file) - elif not auth: - return False - self._basic_auth = auth - return True - return False - - def set_basic_auth(self, username: str, password: str): + def login( + self, + username: str, + password: str, + password_stdin: bool = False, + tls_verify: bool = True, + hostname: Optional[str] = None, + config_path: Optional[List[str]] = None, + ) -> dict: """ - Set basic authentication. + Login to a registry. :param username: the user account name :type username: str :param password: the user account password :type password: str + :param password_stdin: get the password from standard input + :type password_stdin: bool + :param insecure: use http instead of https + :type insecure: bool + :param tls_verify: verify tls + :type tls_verify: bool + :param hostname: the hostname to login to + :type hostname: str + :param config_path: list of config paths to add + :type config_path: list """ - self._basic_auth = oras.auth.get_basic_auth(username, password) - self.set_header("Authorization", "Basic %s" % self._basic_auth) + # Read password from stdin + if password_stdin: + password = oras.utils.readline() - def set_token_auth(self, token: str): - """ - Set token authentication. + # No username, try to get from stdin + if not username: + username = input("Username: ") - :param token: the bearer token - :type token: str - """ - self.token = token - self.set_header("Authorization", "Bearer %s" % token) + # No password provided + if not password: + password = input("Password: ") + if not password: + raise ValueError("password required") - def reset_basic_auth(self): - """ - Given we have basic auth, reset it. - """ - if "Authorization" in self.headers: - del self.headers["Authorization"] - if self._basic_auth: - self.set_header("Authorization", "Basic %s" % self._basic_auth) + # Cut out early if we didn't get what we need + if not password or not username: + return {"Login": "Not successful"} + + # Set basic auth for the auth client + self.auth.set_basic_auth(username, password) + + # Login + # https://docker-py.readthedocs.io/en/stable/client.html?highlight=login#docker.client.DockerClient.login + try: + client = oras.utils.get_docker_client(tls_verify=tls_verify) + return client.login( + username=username, + password=password, + registry=hostname, + dockercfg_path=config_path, + ) + + # Fallback to manual login + except Exception: + return login.DockerClient().login( + username=username, # type: ignore + password=password, # type: ignore + registry=hostname, # type: ignore + dockercfg_path=config_path, + ) def set_header(self, name: str, value: str): """ @@ -226,7 +251,6 @@ def upload_blob( container: container_type, layer: dict, do_chunked: bool = False, - refresh_headers: bool = True, ) -> requests.Response: """ Prepare and upload a blob. @@ -240,26 +264,17 @@ def upload_blob( :type container: oras.container.Container or str :param layer: dict from oras.oci.NewLayer :type layer: dict - :param refresh_headers: if true, headers are refreshed - :type refresh_headers: bool """ blob = os.path.abspath(blob) container = self.get_container(container) - # Always reset headers between uploads - self.reset_basic_auth() - # Chunked for large, otherwise POST and PUT # This is currently disabled unless the user asks for it, as # it doesn't seem to work for all registries if not do_chunked: - response = self.put_upload( - blob, container, layer, refresh_headers=refresh_headers - ) + response = self.put_upload(blob, container, layer) else: - response = self.chunked_upload( - blob, container, layer, refresh_headers=refresh_headers - ) + response = self.chunked_upload(blob, container, layer) # If we have an empty layer digest and the registry didn't accept, just return dummy successful response if ( @@ -485,7 +500,6 @@ def put_upload( blob: str, container: oras.container.Container, layer: dict, - refresh_headers: bool = True, ) -> requests.Response: """ Upload to a registry via put. @@ -496,15 +510,10 @@ def put_upload( :type container: oras.container.Container or str :param layer: dict from oras.oci.NewLayer :type layer: dict - :param refresh_headers: if true, headers are refreshed - :type refresh_headers: bool """ # Start an upload session headers = {"Content-Type": "application/octet-stream"} - if not refresh_headers: - headers.update(self.headers) - upload_url = f"{self.prefix}://{container.upload_blob_url()}" r = self.do_request(upload_url, "POST", headers=headers) @@ -562,7 +571,6 @@ def chunked_upload( blob: str, container: oras.container.Container, layer: dict, - refresh_headers: bool = True, ) -> requests.Response: """ Upload via a chunked upload. @@ -573,13 +581,9 @@ def chunked_upload( :type container: oras.container.Container or str :param layer: dict from oras.oci.NewLayer :type layer: dict - :param refresh_headers: if true, headers are refreshed - :type refresh_headers: bool """ # Start an upload session headers = {"Content-Type": "application/octet-stream", "Content-Length": "0"} - if not refresh_headers: - headers.update(self.headers) upload_url = f"{self.prefix}://{container.upload_blob_url()}" r = self.do_request(upload_url, "POST", headers=headers) @@ -605,7 +609,7 @@ def chunked_upload( } # Important to update with auth token if acquired - headers.update(self.headers) + # TODO call to auth here start = end + 1 self._check_200_response( self.do_request(session_url, "PATCH", data=chunk, headers=headers) @@ -647,7 +651,6 @@ def upload_manifest( self, manifest: dict, container: oras.container.Container, - refresh_headers: bool = True, ) -> requests.Response: """ Read a manifest file and upload it. @@ -656,19 +659,12 @@ def upload_manifest( :type manifest: dict :param container: parsed container URI :type container: oras.container.Container or str - :param refresh_headers: if true, headers are refreshed - :type refresh_headers: bool """ - self.reset_basic_auth() jsonschema.validate(manifest, schema=oras.schemas.manifest) headers = { "Content-Type": oras.defaults.default_manifest_media_type, "Content-Length": str(len(manifest)), } - - if not refresh_headers: - headers.update(self.headers) - return self.do_request( f"{self.prefix}://{container.manifest_url()}", # noqa "PUT", @@ -676,7 +672,17 @@ def upload_manifest( json=manifest, ) - def push(self, *args, **kwargs) -> requests.Response: + def push( + self, + target: str, + config_path: Optional[str] = None, + disable_path_validation: bool = False, + files: Optional[List] = None, + manifest_config: Optional[dict] = None, + annotation_file: Optional[str] = None, + manifest_annotations: Optional[dict] = None, + subject: Optional[str] = None, + ) -> requests.Response: """ Push a set of files to a target @@ -694,30 +700,22 @@ def push(self, *args, **kwargs) -> requests.Response: :type manifest_annotations: dict :param target: target location to push to :type target: str - :param refresh_headers: if true or None, headers are refreshed - :type refresh_headers: bool :param subject: optional subject reference :type subject: Subject """ - container = self.get_container(kwargs["target"]) - self.load_configs(container, configs=kwargs.get("config_path")) - - # Hold state of request for http/https - validate_path = not kwargs.get("disable_path_validation", False) + container = self.get_container(target) + files = files or [] + self.auth.load_configs(container, configs=config_path) # Prepare a new manifest manifest = oras.oci.NewManifest() # A lookup of annotations we can add (to blobs or manifest) - annotset = oras.oci.Annotations(kwargs.get("annotation_file")) + annotset = oras.oci.Annotations(annotation_file) media_type = None - refresh_headers = kwargs.get("refresh_headers") - if refresh_headers is None: - refresh_headers = True - # Upload files as blobs - for blob in kwargs.get("files", []): + for blob in files: # You can provide a blob + content type path_content: PathAndOptionalContent = oras.utils.split_path_and_content( str(blob) @@ -730,7 +728,7 @@ def push(self, *args, **kwargs) -> requests.Response: raise FileNotFoundError(f"{blob} does not exist.") # Path validation means blob must be relative to PWD. - if validate_path: + if not disable_path_validation: if not self._validate_path(blob): raise ValueError( f"Blob {blob} is not in the present working directory context." @@ -761,9 +759,7 @@ def push(self, *args, **kwargs) -> requests.Response: logger.debug(f"Preparing layer {layer}") # Upload the blob layer - response = self.upload_blob( - blob, container, layer, refresh_headers=refresh_headers - ) + response = self.upload_blob(blob, container, layer) self._check_200_response(response) # Do we need to cleanup a temporary targz? @@ -775,18 +771,16 @@ def push(self, *args, **kwargs) -> requests.Response: # Custom manifest annotations from client key=value pairs # These over-ride any potentially provided from file - custom_annots = kwargs.get("manifest_annotations") + custom_annots = copy.deepcopy(manifest_annotations) if custom_annots: manifest_annots.update(custom_annots) if manifest_annots: manifest["annotations"] = manifest_annots - subject = kwargs.get("subject") if subject: manifest["subject"] = asdict(subject) # Prepare the manifest config (temporary or one provided) - manifest_config = kwargs.get("manifest_config") config_annots = annotset.get_annotations("$config") if manifest_config: ref, media_type = self._parse_manifest_ref(manifest_config) @@ -805,21 +799,24 @@ def push(self, *args, **kwargs) -> requests.Response: if config_file is None else nullcontext(config_file) ) as config_file: - response = self.upload_blob( - config_file, container, conf, refresh_headers=refresh_headers - ) + response = self.upload_blob(config_file, container, conf) self._check_200_response(response) # Final upload of the manifest manifest["config"] = conf - self._check_200_response( - self.upload_manifest(manifest, container, refresh_headers=refresh_headers) - ) + self._check_200_response(self.upload_manifest(manifest, container)) print(f"Successfully pushed {container}") return response - def pull(self, *args, **kwargs) -> List[str]: + def pull( + self, + target: str, + config_path: Optional[str] = None, + allowed_media_type: Optional[List] = None, + overwrite: bool = True, + outdir: Optional[str] = None, + ) -> List[str]: """ Pull an artifact from a target @@ -829,8 +826,6 @@ def pull(self, *args, **kwargs) -> List[str]: :type allowed_media_type: list or None :param overwrite: if output file exists, overwrite :type overwrite: bool - :param refresh_headers: if true, headers are refreshed when fetching manifests - :type refresh_headers: bool :param manifest_config_ref: save manifest config to this file :type manifest_config_ref: str :param outdir: output directory path @@ -838,15 +833,11 @@ def pull(self, *args, **kwargs) -> List[str]: :param target: target location to pull from :type target: str """ - allowed_media_type = kwargs.get("allowed_media_type") - refresh_headers = kwargs.get("refresh_headers") - if refresh_headers is None: - refresh_headers = True - container = self.get_container(kwargs["target"]) - self.load_configs(container, configs=kwargs.get("config_path")) - manifest = self.get_manifest(container, allowed_media_type, refresh_headers) - outdir = kwargs.get("outdir") or oras.utils.get_tmpdir() - overwrite = kwargs.get("overwrite", True) + container = self.get_container(target) + self.auth.load_configs(container, configs=config_path) + manifest = self.get_manifest(container, allowed_media_type) + outdir = outdir or oras.utils.get_tmpdir() + overwrite = overwrite files = [] for layer in manifest.get("layers", []): @@ -887,7 +878,6 @@ def get_manifest( self, container: container_type, allowed_media_type: Optional[list] = None, - refresh_headers: bool = True, ) -> dict: """ Retrieve a manifest for a package. @@ -896,16 +886,11 @@ def get_manifest( :type container: oras.container.Container or str :param allowed_media_type: one or more allowed media types :type allowed_media_type: str - :param refresh_headers: if true, headers are refreshed - :type refresh_headers: bool """ if not allowed_media_type: allowed_media_type = [oras.defaults.default_manifest_media_type] headers = {"Accept": ";".join(allowed_media_type)} - if not refresh_headers: - headers.update(self.headers) - get_manifest = f"{self.prefix}://{container.manifest_url()}" # type: ignore response = self.do_request(get_manifest, "GET", headers=headers) self._check_200_response(response) @@ -939,8 +924,6 @@ def do_request( :param stream: stream the responses :type stream: bool """ - headers = headers or {} - # Make the request and return to calling function, unless requires auth response = self.session.request( method, @@ -952,144 +935,33 @@ def do_request( verify=self._tls_verify, ) - # A 401 response is a request for authentication - if response.status_code not in [401, 404]: + # A 401 response is a request for authentication, 404 is not found + if response.status_code not in [401, 403]: return response # Otherwise, authenticate the request and retry - if self.authenticate_request(response): - headers.update(self.headers) - response = self.session.request( - method, - url, - data=data, - json=json, - headers=headers, - stream=stream, - ) - - # Fallback to using Authorization if already required - # This is a catch for EC2. I don't think this is correct - # A basic token should be used for a bearer one. - if response.status_code in [401, 404] and "Authorization" in self.headers: - logger.debug("Trying with provided Basic Authorization...") - headers.update(self.headers) - response = self.session.request( - method, - url, - data=data, - json=json, - headers=headers, - stream=stream, - ) - - return response - - def authenticate_request(self, originalResponse: requests.Response) -> bool: - """ - Authenticate Request - Given a response, look for a Www-Authenticate header to parse. - - We return True/False to indicate if the request should be retried. - - :param originalResponse: original response to get the Www-Authenticate header - :type originalResponse: requests.Response - """ - authHeaderRaw = originalResponse.headers.get("Www-Authenticate") - if not authHeaderRaw: - logger.debug( - "Www-Authenticate not found in original response, cannot authenticate." - ) - return False - - # If we have a token, set auth header (base64 encoded user/pass) - if self.token: - self.set_header("Authorization", "Bearer %s" % self._basic_auth) - return True - - headers = copy.deepcopy(self.headers) - h = oras.auth.parse_auth_header(authHeaderRaw) - - if "Authorization" not in headers: - # First try to request an anonymous token - logger.debug("No Authorization, requesting anonymous token") - if self.request_anonymous_token(h): - logger.debug("Successfully obtained anonymous token!") - return True - - logger.error( - "This endpoint requires a token. Please set " - "oras.provider.Registry.set_basic_auth(username, password) " - "first or use oras-py login to do the same." - ) - return False + headers, changed = self.auth.authenticate_request(response, headers) + if not changed: + logger.exit("Cannot respond to request for authentication.") + response = self.session.request( + method, + url, + data=data, + json=json, + headers=headers, + stream=stream, + ) - params = {} - - # Prepare request to retry - if h.service: - logger.debug(f"Service: {h.service}") - params["service"] = h.service - headers.update( - { - "Service": h.service, - "Accept": "application/json", - "User-Agent": "oras-py", - } + # One retry if 403 denied (need new token?) + if response.status_code == 403: + headers, changed = self.auth.authenticate_request( + response, headers, refresh=True ) - - # Ensure the realm starts with http - if not h.realm.startswith("http"): # type: ignore - h.realm = f"{self.prefix}://{h.realm}" - - # If the www-authenticate included a scope, honor it! - if h.scope: - logger.debug(f"Scope: {h.scope}") - params["scope"] = h.scope - - authResponse = self.session.get(h.realm, headers=headers, params=params) # type: ignore - if authResponse.status_code != 200: - logger.debug(f"Auth response was not successful: {authResponse.text}") - return False - - # Request the token - info = authResponse.json() - token = info.get("token") or info.get("access_token") - - # Set the token to the original request and retry - self.headers.update({"Authorization": "Bearer %s" % token}) - return True - - def request_anonymous_token(self, h: oras.auth.authHeader) -> bool: - """ - Given no basic auth, fall back to trying to request an anonymous token. - - Returns: boolean if headers have been updated with token. - """ - if not h.realm: - logger.debug("Request anonymous token: no realm provided, exiting early") - return False - - params = {} - if h.service: - params["service"] = h.service - if h.scope: - params["scope"] = h.scope - - logger.debug(f"Final params are {params}") - response = self.session.request("GET", h.realm, params=params) - if response.status_code != 200: - logger.debug(f"Response for anon token failed: {response.text}") - return False - - # From https://docs.docker.com/registry/spec/auth/token/ section - # We can get token OR access_token OR both (when both they are identical) - data = response.json() - token = data.get("token") or data.get("access_token") - - # Update the headers but not self.token (expects Basic) - if token: - self.headers.update({"Authorization": "Bearer %s" % token}) - return True - logger.debug("Warning: no token or access_token present in response.") - return False + return self.session.request( + method, + url, + data=data, + json=json, + headers=headers, + stream=stream, + ) diff --git a/oras/tests/test_oras.py b/oras/tests/test_oras.py index 06df44f..03b7a77 100644 --- a/oras/tests/test_oras.py +++ b/oras/tests/test_oras.py @@ -85,6 +85,7 @@ def test_get_delete_tags(tmp_path, registry, credentials, target): # Test deleting not-existence tag assert not client.delete_tags(target, "v1-boop-boop") + assert "v1" in client.delete_tags(target, "v1") tags = client.get_tags(target) assert not tags diff --git a/oras/types.py b/oras/types.py new file mode 100644 index 0000000..b47f4ef --- /dev/null +++ b/oras/types.py @@ -0,0 +1,10 @@ +__author__ = "Vanessa Sochat" +__copyright__ = "Copyright The ORAS Authors." +__license__ = "Apache-2.0" + +from typing import Union + +import oras.container + +# container type can be string or container +container_type = Union[str, oras.container.Container] diff --git a/oras/version.py b/oras/version.py index 5fa3cc4..c3a2556 100644 --- a/oras/version.py +++ b/oras/version.py @@ -2,7 +2,7 @@ __copyright__ = "Copyright The ORAS Authors." __license__ = "Apache-2.0" -__version__ = "0.1.29" +__version__ = "0.2.0" AUTHOR = "Vanessa Sochat" EMAIL = "vsoch@users.noreply.github.com" NAME = "oras" diff --git a/scripts/test.sh b/scripts/test.sh index 33a0a3a..47510f2 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -20,4 +20,4 @@ printf "ORAS_REGISTRY: ${ORAS_REGISTRY}\n" printf "ORAS_AUTH: ${ORAS_AUTH}\n" # Client (command line) tests -pytest oras/ +pytest -xs oras/ From 74e05748e6730a84c77fa2fffe1696dd3aedbc00 Mon Sep 17 00:00:00 2001 From: vsoch Date: Fri, 10 May 2024 23:44:14 -0600 Subject: [PATCH 2/3] feat: add devcontainer Signed-off-by: vsoch --- .devcontainer/Dockerfile | 34 +++++++++++++++++++++++++ .devcontainer/devcontainer.json | 16 ++++++++++++ .github/dev-requirements.txt | 2 +- .pre-commit-config.yaml | 2 +- docs/getting_started/developer-guide.md | 9 ++++++- examples/conda-mirror.py | 1 - examples/follow-image-index.py | 1 + examples/simple/login.py | 3 +-- examples/simple/pull.py | 8 +++--- examples/simple/push.py | 2 -- oras/tests/test_oras.py | 1 - 11 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..2eef6d6 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,34 @@ +FROM ghcr.io/oras-project/registry:latest + +LABEL maintainer="Vanessasaurus <@vsoch>" + +RUN apk update && apk add python3 py3-pip git make apache2-utils bash && \ + pip install --upgrade pip setuptools + +ENV registry_host=localhost +ENV registry_port=5000 +ENV with_auth=true +ENV REGISTRY_AUTH: "{htpasswd: {realm: localhost, path: /etc/docker/registry/auth.htpasswd}}" +ENV REGISTRY_STORAGE_DELETE_ENABLED="true" +RUN htpasswd -cB -b auth.htpasswd myuser mypass && \ + cp auth.htpasswd /etc/docker/registry/auth.htpasswd && \ + registry serve /etc/docker/registry/config.yml & sleep 5 && \ + echo $PWD && ls $PWD + +# Match the default user id for a single system so we aren't root +ARG USERNAME=vscode +ARG USER_UID=1000 +ARG USER_GID=1000 +ENV USERNAME=${USERNAME} +ENV USER_UID=${USER_UID} +ENV USER_GID=${USER_GID} +ENV GO_VERSION=1.21.9 + + +# Add the group and user that match our ids +RUN addgroup -S ${USERNAME} && adduser -S ${USERNAME} -G ${USERNAME} && \ + echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers + +USER $USERNAME +# make install +# make test diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..130f5f4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +{ + "name": "Oras Python with Auth Development Environment", + "dockerFile": "Dockerfile", + "context": "../", + + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "bash" + }, + "extensions": [ + ] + } + }, + "postStartCommand": "git config --global --add safe.directory /workspaces/oras-py" +} diff --git a/.github/dev-requirements.txt b/.github/dev-requirements.txt index a872fd6..66ee38f 100644 --- a/.github/dev-requirements.txt +++ b/.github/dev-requirements.txt @@ -1,5 +1,5 @@ pre-commit -black==24.1.0 +black==24.3.0 isort flake8 mypy==0.961 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fde1980..350f580 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: hooks: - id: isort - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 24.3.0 hooks: - id: black language_version: python3.11 diff --git a/docs/getting_started/developer-guide.md b/docs/getting_started/developer-guide.md index 06e4328..30ea68f 100644 --- a/docs/getting_started/developer-guide.md +++ b/docs/getting_started/developer-guide.md @@ -6,7 +6,6 @@ read [the installation guide](installation.md) you should do that first. If you want to see a more general user guide with examples for using the SDK and writing clients, see our [user guide](user-guide.md). - ## Running Tests You'll want to create an environment to install to, and then install: @@ -24,6 +23,14 @@ $ docker run -it --rm -p 5000:5000 ghcr.io/oras-project/registry:latest Zot is a good solution too: ```bash +docker run -d -p 5000:5000 --name oras-quickstart ghcr.io/project-zot/zot-linux-amd64:latest +``` + +For quick auth, you can use the included Developer container and do: + +```bash +make install +make test ``` And then when you run `make test`, the tests will run. This ultimately diff --git a/examples/conda-mirror.py b/examples/conda-mirror.py index a74be55..fa09c11 100644 --- a/examples/conda-mirror.py +++ b/examples/conda-mirror.py @@ -85,7 +85,6 @@ def _organize_layers(self, manifest: dict) -> dict: def main(): mirror = CondaMirror() - mirror.set_basic_auth(user, token) mirror.inspect("ghcr.io/wolfv/conda-forge/linux-64/xtensor:0.9.0-0") diff --git a/examples/follow-image-index.py b/examples/follow-image-index.py index 596b890..9e17860 100644 --- a/examples/follow-image-index.py +++ b/examples/follow-image-index.py @@ -1,6 +1,7 @@ """ Follow homebrew image index to get the 'hello' bottle specific to your platform """ + import re import oras.client diff --git a/examples/simple/login.py b/examples/simple/login.py index bd24ad9..4af2eeb 100644 --- a/examples/simple/login.py +++ b/examples/simple/login.py @@ -82,7 +82,7 @@ def main(args): 5. We first try using the docker-py login. 6. If it fails we fall back to custom setting of credentials. """ - client = oras.client.OrasClient() + client = oras.client.OrasClient(insecre=args.insecure) print(client.version()) # Other ways to handle login: @@ -95,7 +95,6 @@ def main(args): username=args.username, config_path=args.config, hostname=args.hostname, - insecure=args.insecure, password_stdin=args.password_stdin, ) logger.info(result) diff --git a/examples/simple/pull.py b/examples/simple/pull.py index f2f6cca..a303903 100644 --- a/examples/simple/pull.py +++ b/examples/simple/pull.py @@ -20,13 +20,11 @@ def main(args): client = oras.client.OrasClient(insecure=args.insecure) print(client.version()) try: - if args.username and args.password: - client.set_basic_auth(args.username, args.password) client.pull( config_path=args.config, - allowed_media_type=args.allowed_media_type - if not args.allow_all_media_types - else [], + allowed_media_type=( + args.allowed_media_type if not args.allow_all_media_types else [] + ), overwrite=not args.keep_old_files, outdir=args.output, target=args.target, diff --git a/examples/simple/push.py b/examples/simple/push.py index e4f7301..a235a49 100644 --- a/examples/simple/push.py +++ b/examples/simple/push.py @@ -51,8 +51,6 @@ def main(args): ) client = oras.client.OrasClient(insecure=args.insecure) try: - if args.username and args.password: - client.set_basic_auth(args.username, args.password) client.push( config_path=args.config, disable_path_validation=args.disable_path_validation, diff --git a/oras/tests/test_oras.py b/oras/tests/test_oras.py index 03b7a77..82ebd31 100644 --- a/oras/tests/test_oras.py +++ b/oras/tests/test_oras.py @@ -30,7 +30,6 @@ def test_login_logout(registry, credentials): hostname=registry, username=credentials.user, password=credentials.password, - insecure=True, ) assert res["Status"] == "Login Succeeded" client.logout(registry) From 1be35e7a750dbee56a17454ef4aabc2554ceed4a Mon Sep 17 00:00:00 2001 From: vsoch Date: Sat, 1 Jun 2024 14:09:28 -0600 Subject: [PATCH 3/3] fix: redundant subject Signed-off-by: vsoch --- oras/oci.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/oras/oci.py b/oras/oci.py index 880d5c9..4865f8e 100644 --- a/oras/oci.py +++ b/oras/oci.py @@ -24,13 +24,6 @@ } -@dataclass -class Subject: - mediaType: str - digest: str - size: int - - class Annotations: """ Create a new set of annotations