Add OIDC authentication support to DC CLI
This commit introduces OIDC authentication to the DC CLI
while preserving Keystone as the default authentication method.
Behavior & configuration:
- New authentication selector available as:
* Environment variable: STX_AUTH_TYPE={keystone|oidc}
* CLI parameter: --stx-auth-type keystone|oidc
- Default: keystone (existing behavior)
- Keystone flow (default or when STX_AUTH_TYPE/--stx-auth-type=keystone):
- Unchanged; authenticate with Keystone using X-Auth-Token.
- OIDC flow (when STX_AUTH_TYPE/--stx-auth-type=oidc):
- Obtain OIDC ID/Refresh tokens from $KUBECONFIG (or ~/.kube/config
if not specified); no local token discovery beyond kubeconfig.
- Send OIDC ID-Token via custom HTTPS header: OIDC-Token.
- Build REST API URL without Keystone catalog:
* Protocol from OS_INTERFACE: public/admin=https, internal=http.
* IP/host from OS_AUTH_URL (MGMT locally, OAM remotely).
* Hard-code port and API path per interface (v1/v3 variants),
including region dimension (RegionOne vs SystemController).
Test Plan:
PASS: CLI authenticates with Keystone by default or with
STX_AUTH_TYPE/--stx-auth-type=keystone.
PASS: CLI authenticates with OIDC when
STX_AUTH_TYPE/--stx-auth-type=oidc is specified.
PASS: CLI reads OIDC ID/Refresh tokens from kubeconfig.
PASS: CLI sends OIDC-Token header in OIDC mode.
PASS: In an https enabled system, run a dcmanager command with
--os-endpoint-type parameter set to public and the --os-auth-url
pointing to the oam ip and verify the request succeeds
Depends-On: https://review.opendev.org/c/starlingx/distcloud/+/966303
Story: 2011646
Task: 53595
Change-Id: Iff55f653258bdf40247baf3490943f884d41d781
Signed-off-by: Hugo Brito <hugo.brito@windriver.com>
This commit is contained in:
@@ -30,6 +30,7 @@ nosetests.xml
|
||||
.stestr
|
||||
.testrepository
|
||||
.venv
|
||||
test.yaml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
parent: tox-py39
|
||||
description: Run py39 for distcloud-client
|
||||
nodeset: debian-bullseye
|
||||
required-projects:
|
||||
- starlingx/utilities
|
||||
vars:
|
||||
python_version: 3.9
|
||||
tox_envlist: py39
|
||||
@@ -37,6 +39,8 @@
|
||||
parent: tox
|
||||
description: Run pylint for distcloud-client
|
||||
nodeset: debian-bullseye
|
||||
required-projects:
|
||||
- starlingx/utilities
|
||||
vars:
|
||||
python_version: 3.9
|
||||
tox_envlist: pylint
|
||||
@@ -47,6 +51,8 @@
|
||||
parent: tox
|
||||
description: Run pep8 for distcloud-client
|
||||
nodeset: debian-bullseye
|
||||
required-projects:
|
||||
- starlingx/utilities
|
||||
vars:
|
||||
python_version: 3.9
|
||||
tox_envlist: pep8
|
||||
@@ -57,6 +63,8 @@
|
||||
parent: tox
|
||||
description: Run black for distcloud-client
|
||||
nodeset: debian-bullseye
|
||||
required-projects:
|
||||
- starlingx/utilities
|
||||
vars:
|
||||
python_version: 3.9
|
||||
tox_envlist: black_check
|
||||
|
||||
@@ -92,7 +92,7 @@ ignore-mixin-members=yes
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis
|
||||
ignored-modules=distutils,eventlet.green.subprocess
|
||||
ignored-modules=distutils,platform_util,eventlet.green.subprocess
|
||||
|
||||
# List of classes names for which member attributes should not be checked
|
||||
# (useful for classes with attributes dynamically set).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright 2013 - Mirantis, Inc.
|
||||
# Copyright 2016 - StackStorm, Inc.
|
||||
# Copyright 2016 - Ericsson AB.
|
||||
# Copyright (c) 2017-2021, 2024 Wind River Systems, Inc.
|
||||
# Copyright (c) 2017-2021, 2024, 2026 Wind River Systems, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -51,11 +51,13 @@ class HTTPClient:
|
||||
user_id=None,
|
||||
cacert=None,
|
||||
insecure=False,
|
||||
auth_type="keystone",
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.token = token
|
||||
self.project_id = project_id
|
||||
self.user_id = user_id
|
||||
self.auth_type = auth_type
|
||||
self.ssl_options = {}
|
||||
|
||||
if self.base_url.startswith("https"):
|
||||
@@ -120,7 +122,12 @@ class HTTPClient:
|
||||
|
||||
token = headers.get("x-auth-token", self.token)
|
||||
if token:
|
||||
headers["x-auth-token"] = token
|
||||
if self.auth_type == "oidc":
|
||||
# For OIDC authentication, set OIDC-Token header
|
||||
headers["OIDC-Token"] = token
|
||||
else:
|
||||
# For Keystone authentication, set X-Auth-Token header
|
||||
headers["x-auth-token"] = token
|
||||
|
||||
project_id = headers.get("X-Project-Id", self.project_id)
|
||||
if project_id:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright 2014 - Mirantis, Inc.
|
||||
# Copyright 2015 - StackStorm, Inc.
|
||||
# Copyright 2016 - Ericsson AB.
|
||||
# Copyright (c) 2017-2025 Wind River Systems, Inc.
|
||||
# Copyright (c) 2017-2026 Wind River Systems, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,10 +19,12 @@
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import keystoneauth1.identity.generic as auth_plugin
|
||||
import osprofiler.profiler
|
||||
from keystoneauth1 import session as ks_session
|
||||
import osprofiler.profiler
|
||||
from platform_util.oidc import oidc_utils
|
||||
|
||||
from dcmanagerclient import utils
|
||||
from dcmanagerclient.api import httpclient
|
||||
@@ -47,6 +49,7 @@ from dcmanagerclient.api.v1.sw_prestage_manager import SwPrestageManager
|
||||
from dcmanagerclient.api.v1.sw_strategy_manager import SwStrategyManager
|
||||
from dcmanagerclient.api.v1.sw_update_options_manager import SwUpdateOptionsManager
|
||||
from dcmanagerclient.api.v1.system_peer_manager import SystemPeerManager
|
||||
from dcmanagerclient import exceptions
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
_DEFAULT_DCMANAGER_URL = "http://localhost:8119/v1.0"
|
||||
@@ -68,7 +71,7 @@ class Client:
|
||||
api_key=None,
|
||||
project_name=None,
|
||||
auth_url=None,
|
||||
project_id=None,
|
||||
project_id="admin",
|
||||
endpoint_type="publicURL",
|
||||
service_type="dcmanager",
|
||||
auth_token=None,
|
||||
@@ -108,10 +111,23 @@ class Client:
|
||||
refresh_cache=refresh_cache,
|
||||
**kwargs,
|
||||
)
|
||||
elif auth_type == "oidc":
|
||||
if not username:
|
||||
raise RuntimeError("Username is required for OIDC authentication")
|
||||
|
||||
try:
|
||||
(dcmanager_url, auth_token) = _get_oidc_data(
|
||||
username,
|
||||
auth_url,
|
||||
endpoint_type,
|
||||
service_type,
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"OIDC authentication failed: {e}") from e
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Invalid authentication type "
|
||||
f"[value={auth_type}, valid_values=keystone]"
|
||||
f"[value={auth_type}, valid_values=keystone,oidc]"
|
||||
)
|
||||
|
||||
if not dcmanager_url:
|
||||
@@ -127,6 +143,7 @@ class Client:
|
||||
user_id,
|
||||
cacert=cacert,
|
||||
insecure=insecure,
|
||||
auth_type=auth_type,
|
||||
)
|
||||
|
||||
# Create all managers
|
||||
@@ -182,6 +199,8 @@ def authenticate(
|
||||
user_domain_id = kwargs.get("user_domain_id")
|
||||
project_domain_name = kwargs.get("project_domain_name")
|
||||
project_domain_id = kwargs.get("project_domain_id")
|
||||
token = None
|
||||
verify = False if insecure else (cacert if cacert else True)
|
||||
|
||||
if session is None:
|
||||
if auth_token:
|
||||
@@ -192,8 +211,6 @@ def authenticate(
|
||||
project_name=project_name,
|
||||
project_domain_name=project_domain_name,
|
||||
project_domain_id=project_domain_id,
|
||||
cacert=cacert,
|
||||
insecure=insecure,
|
||||
)
|
||||
|
||||
elif api_key and (username or user_id):
|
||||
@@ -212,11 +229,10 @@ def authenticate(
|
||||
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"You must either provide a valid token or"
|
||||
"a password (api_key) and a user."
|
||||
"You must either provide a valid token or "
|
||||
"a password (api_key) and a username."
|
||||
)
|
||||
if auth:
|
||||
session = ks_session.Session(auth=auth)
|
||||
session = ks_session.Session(auth=auth, verify=verify)
|
||||
|
||||
if session:
|
||||
token, cache_key = None, _cache_key(username)
|
||||
@@ -251,3 +267,100 @@ def authenticate(
|
||||
)
|
||||
|
||||
return dcmanager_url, token, project_id, user_id
|
||||
|
||||
|
||||
def _get_oidc_data(username, auth_url, endpoint_type, service_type):
|
||||
"""Get OIDC token and dcmanager URL.
|
||||
|
||||
Args:
|
||||
username: Username for OIDC token lookup
|
||||
auth_url: OS_AUTH_URL for service URL construction
|
||||
endpoint_type: Interface type (publicURL, internalURL, adminURL)
|
||||
service_type: Service type (dcmanager)
|
||||
|
||||
Returns:
|
||||
tuple: (dcmanager_url, oidc_token)
|
||||
"""
|
||||
dcmanager_url = _build_service_url(auth_url, service_type, endpoint_type)
|
||||
|
||||
# Client should ALWAYS read OIDC Token from $KUBECONFIG file, no caching
|
||||
oidc_token = oidc_utils.get_oidc_token(username)
|
||||
if not oidc_token:
|
||||
raise exceptions.DCManagerClientException(
|
||||
f"No OIDC token found for user '{username}' in kubeconfig"
|
||||
)
|
||||
|
||||
return dcmanager_url, oidc_token
|
||||
|
||||
|
||||
def _build_service_url(auth_url, service_type, endpoint_type):
|
||||
"""Build service URL from auth_url and service parameters.
|
||||
|
||||
Args:
|
||||
auth_url: OS_AUTH_URL containing the IP address
|
||||
service_type: Service type (e.g., 'dcmanager')
|
||||
endpoint_type: Interface type (publicURL, internalURL, adminURL)
|
||||
|
||||
Returns:
|
||||
str: Complete service URL
|
||||
"""
|
||||
if not auth_url:
|
||||
raise exceptions.DCManagerClientException(
|
||||
"OS_AUTH_URL is required for OIDC authentication"
|
||||
)
|
||||
|
||||
# Parse IP address from auth_url
|
||||
parsed = urlparse(auth_url)
|
||||
ip_address = parsed.hostname
|
||||
|
||||
if not ip_address:
|
||||
raise exceptions.DCManagerClientException(
|
||||
f"Cannot extract IP address from OS_AUTH_URL: {auth_url}"
|
||||
)
|
||||
|
||||
# Map endpoint types to interface names
|
||||
interface_map = {
|
||||
"publicURL": "public",
|
||||
"internalURL": "internal",
|
||||
"adminURL": "admin",
|
||||
}
|
||||
|
||||
interface = interface_map.get(endpoint_type)
|
||||
|
||||
# Service port and path mappings
|
||||
service_ports = {
|
||||
"public": {"dcmanager": 8119},
|
||||
"internal": {"dcmanager": 8119},
|
||||
"admin": {"dcmanager": 8120},
|
||||
}
|
||||
|
||||
service_paths = {
|
||||
"public": {"dcmanager": "/v1.0"},
|
||||
"internal": {"dcmanager": "/v1.0"},
|
||||
"admin": {"dcmanager": "/v1.0"},
|
||||
}
|
||||
|
||||
# Determine protocol
|
||||
# HTTPS should always be enabled for public endpoints when using OIDC
|
||||
if interface in ["public", "admin"]:
|
||||
protocol = "https"
|
||||
else:
|
||||
protocol = "http"
|
||||
|
||||
# Get port and path
|
||||
port = service_ports.get(interface, {}).get(service_type)
|
||||
path = service_paths.get(interface, {}).get(service_type)
|
||||
|
||||
if not port or not path:
|
||||
raise exceptions.DCManagerClientException(
|
||||
f"No port/path mapping for service {service_type} on "
|
||||
f"interface {interface}"
|
||||
)
|
||||
|
||||
# Format IP address for URL (IPv6 needs brackets)
|
||||
if ":" in ip_address and not ip_address.startswith("["):
|
||||
formatted_ip = f"[{ip_address}]"
|
||||
else:
|
||||
formatted_ip = ip_address
|
||||
|
||||
return f"{protocol}://{formatted_ip}:{port}{path}"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copyright 2015 - Ericsson AB.
|
||||
# Copyright (c) 2017-2025 Wind River Systems, Inc.
|
||||
# Copyright (c) 2017-2026 Wind River Systems, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -529,6 +529,15 @@ class DCManagerShell(app.App):
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--stx-auth-type",
|
||||
action="store",
|
||||
dest="stx_auth_type",
|
||||
default=env("STX_AUTH_TYPE", default="keystone"),
|
||||
choices=["keystone", "oidc"],
|
||||
help="Authentication type: keystone or oidc (Env: STX_AUTH_TYPE)",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
def print_help_if_requested(self):
|
||||
@@ -538,28 +547,35 @@ class DCManagerShell(app.App):
|
||||
|
||||
def load_client(self, refresh_cache, skip_auth=True):
|
||||
if self.options.auth_url and not self.options.token and not skip_auth:
|
||||
if not self.options.tenant_name:
|
||||
raise exceptions.CommandError(
|
||||
(
|
||||
"You must provide a tenant_name "
|
||||
"via --os-tenantname env[OS_TENANT_NAME]"
|
||||
if getattr(self.options, "stx_auth_type", "keystone") == "oidc":
|
||||
if not self.options.username:
|
||||
raise exceptions.CommandError(
|
||||
"You must provide a username via --os-username "
|
||||
"env[OS_USERNAME] for OIDC authentication"
|
||||
)
|
||||
)
|
||||
if not self.options.username:
|
||||
raise exceptions.CommandError(
|
||||
(
|
||||
"You must provide a username "
|
||||
"via --os-username env[OS_USERNAME]"
|
||||
else:
|
||||
if not self.options.tenant_name:
|
||||
raise exceptions.CommandError(
|
||||
(
|
||||
"You must provide a tenant_name "
|
||||
"via --os-tenantname env[OS_TENANT_NAME]"
|
||||
)
|
||||
)
|
||||
if not self.options.username:
|
||||
raise exceptions.CommandError(
|
||||
(
|
||||
"You must provide a username "
|
||||
"via --os-username env[OS_USERNAME]"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if not self.options.password:
|
||||
raise exceptions.CommandError(
|
||||
(
|
||||
"You must provide a password "
|
||||
"via --os-password env[OS_PASSWORD]"
|
||||
if not self.options.password:
|
||||
raise exceptions.CommandError(
|
||||
(
|
||||
"You must provide a password "
|
||||
"via --os-password env[OS_PASSWORD]"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"user_domain_name": self.options.user_domain_name,
|
||||
@@ -568,6 +584,8 @@ class DCManagerShell(app.App):
|
||||
"project_domain_id": self.options.project_domain_id,
|
||||
}
|
||||
|
||||
auth_type = getattr(self.options, "stx_auth_type", "keystone")
|
||||
|
||||
self.client = client.client(
|
||||
dcmanager_url=self.options.dcmanager_url,
|
||||
username=self.options.username,
|
||||
@@ -583,6 +601,7 @@ class DCManagerShell(app.App):
|
||||
profile=self.options.profile,
|
||||
refresh_cache=refresh_cache,
|
||||
cache_allowed=self.options.no_cache is False,
|
||||
auth_type=auth_type,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright 2015 - Huawei Technologies Co., Ltd.
|
||||
# Copyright 2016 - StackStorm, Inc.
|
||||
# Copyright 2016 - Ericsson AB.
|
||||
# Copyright (c) 2017, 2019, 2021, 2024 Wind River Systems, Inc.
|
||||
# Copyright (c) 2017, 2019, 2021, 2024, 2026 Wind River Systems, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -25,6 +25,7 @@ import osprofiler.profiler
|
||||
import testtools
|
||||
|
||||
from dcmanagerclient.api import client
|
||||
from dcmanagerclient.api.v1.client import _build_service_url
|
||||
|
||||
AUTH_HTTP_URL = "http://localhost:35357/v3"
|
||||
AUTH_HTTPS_URL = AUTH_HTTP_URL.replace("http", "https")
|
||||
@@ -53,7 +54,11 @@ class BaseClientTests(testtools.TestCase):
|
||||
|
||||
expected_args = (DCMANAGER_HTTP_URL, token, project_id, user_id)
|
||||
|
||||
expected_kwargs = {"cacert": None, "insecure": False}
|
||||
expected_kwargs = {
|
||||
"auth_type": "keystone",
|
||||
"cacert": None,
|
||||
"insecure": False,
|
||||
}
|
||||
|
||||
client.client(
|
||||
username="dcmanager",
|
||||
@@ -81,7 +86,11 @@ class BaseClientTests(testtools.TestCase):
|
||||
|
||||
expected_args = (DCMANAGER_HTTPS_URL, token, project_id, user_id)
|
||||
|
||||
expected_kwargs = {"cacert": None, "insecure": True}
|
||||
expected_kwargs = {
|
||||
"auth_type": "keystone",
|
||||
"cacert": None,
|
||||
"insecure": True,
|
||||
}
|
||||
|
||||
client.client(
|
||||
dcmanager_url=DCMANAGER_HTTPS_URL,
|
||||
@@ -112,7 +121,11 @@ class BaseClientTests(testtools.TestCase):
|
||||
|
||||
expected_args = (DCMANAGER_HTTPS_URL, token, project_id, user_id)
|
||||
|
||||
expected_kwargs = {"cacert": path, "insecure": False}
|
||||
expected_kwargs = {
|
||||
"auth_type": "keystone",
|
||||
"cacert": path,
|
||||
"insecure": False,
|
||||
}
|
||||
|
||||
try:
|
||||
client.client(
|
||||
@@ -185,7 +198,11 @@ class BaseClientTests(testtools.TestCase):
|
||||
|
||||
expected_args = (DCMANAGER_HTTP_URL, token, project_id, user_id)
|
||||
|
||||
expected_kwargs = {"cacert": None, "insecure": False}
|
||||
expected_kwargs = {
|
||||
"auth_type": "keystone",
|
||||
"cacert": None,
|
||||
"insecure": False,
|
||||
}
|
||||
|
||||
client.client(
|
||||
username="dcmanager",
|
||||
@@ -238,3 +255,59 @@ class BaseClientTests(testtools.TestCase):
|
||||
auth_url=AUTH_HTTP_URL,
|
||||
**FAKE_KWARGS
|
||||
)
|
||||
|
||||
@mock.patch("dcmanagerclient.api.v1.client._get_oidc_data")
|
||||
@mock.patch("dcmanagerclient.api.httpclient.HTTPClient")
|
||||
def test_oidc_auth_success(self, mock_client, mock_oidc_data):
|
||||
mock_oidc_data.return_value = (DCMANAGER_HTTP_URL, "oidc_token_123")
|
||||
|
||||
expected_args = (DCMANAGER_HTTP_URL, "oidc_token_123", None, None)
|
||||
expected_kwargs = {
|
||||
"auth_type": "oidc",
|
||||
"cacert": None,
|
||||
"insecure": False,
|
||||
}
|
||||
|
||||
client.client(username="test_user", auth_url=AUTH_HTTP_URL, auth_type="oidc")
|
||||
|
||||
self.assertTrue(mock_client.called)
|
||||
self.assertEqual(mock_client.call_args[0], expected_args)
|
||||
self.assertDictEqual(mock_client.call_args[1], expected_kwargs)
|
||||
mock_oidc_data.assert_called_once_with(
|
||||
"test_user",
|
||||
AUTH_HTTP_URL,
|
||||
"publicURL",
|
||||
"dcmanager",
|
||||
)
|
||||
|
||||
def test_oidc_auth_missing_username(self):
|
||||
self.assertRaises(
|
||||
RuntimeError, client.client, auth_url=AUTH_HTTP_URL, auth_type="oidc"
|
||||
)
|
||||
|
||||
def test_invalid_auth_type(self):
|
||||
self.assertRaises(
|
||||
RuntimeError,
|
||||
client.client,
|
||||
username="test_user",
|
||||
auth_url=AUTH_HTTP_URL,
|
||||
auth_type="invalid_auth",
|
||||
)
|
||||
|
||||
def test_build_service_url_ipv4_public(self):
|
||||
result = _build_service_url(
|
||||
"http://192.168.1.1:5000/v3", "dcmanager", "publicURL"
|
||||
)
|
||||
self.assertEqual(result, "https://192.168.1.1:8119/v1.0")
|
||||
|
||||
def test_build_service_url_ipv6_admin(self):
|
||||
result = _build_service_url(
|
||||
"http://[2001:db8::1]:5000/v3", "dcmanager", "adminURL"
|
||||
)
|
||||
self.assertEqual(result, "https://[2001:db8::1]:8120/v1.0")
|
||||
|
||||
def test_build_service_url_ipv6_internal(self):
|
||||
result = _build_service_url(
|
||||
"http://[fd00::1]:5000/v3", "dcmanager", "internalURL"
|
||||
)
|
||||
self.assertEqual(result, "http://[fd00::1]:8119/v1.0")
|
||||
|
||||
@@ -11,3 +11,5 @@ python-keystoneclient>=3.8.0 # Apache-2.0
|
||||
PyYAML>=3.10.0 # MIT
|
||||
requests!=2.12.2,!=2.13.0,>=2.10.0 # Apache-2.0
|
||||
requests-toolbelt # Apache-2.0
|
||||
oic
|
||||
pyjwkest
|
||||
|
||||
@@ -6,6 +6,7 @@ toxworkdir = /tmp/{env:USER}_dc_client_tox
|
||||
|
||||
[dcclient]
|
||||
client_base_dir = .
|
||||
platform_util_src_dir = ../../utilities/utilities/platform-util/platform-util
|
||||
|
||||
[testenv]
|
||||
basepython = python3.9
|
||||
@@ -19,6 +20,7 @@ deps =
|
||||
-c{env:UPPER_CONSTRAINTS_FILE:https://opendev.org/starlingx/root/raw/branch/master/build-tools/requirements/debian/upper-constraints.txt}
|
||||
-r{toxinidir}/requirements.txt
|
||||
-r{toxinidir}/test-requirements.txt
|
||||
-e{[dcclient]platform_util_src_dir}
|
||||
|
||||
allowlist_externals =
|
||||
rm
|
||||
|
||||
Reference in New Issue
Block a user