Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable support for Vault secrets in Manifester #38

Merged
merged 2 commits into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
vault-login:
@scripts/vault_login.py --login

vault-logout:
@scripts/vault_login.py --logout

vault-status:
@scripts/vault_login.py --status
7 changes: 6 additions & 1 deletion manifester/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,13 @@ def delete(allocations, all_, remove_manifest_file):
uuid=allocation.get("uuid")
)
if remove_manifest_file:
manifester_directory = (
Path(os.environ["MANIFESTER_DIRECTORY"]).resolve()
if "MANIFESTER_DIRECTORY" in os.environ
else Path()
)
Path(
f"{os.environ['MANIFESTER_DIRECTORY']}/manifests/{allocation.get('name')}_manifest.zip"
f"{manifester_directory}/manifests/{allocation.get('name')}_manifest.zip"
).unlink()


Expand Down
145 changes: 145 additions & 0 deletions manifester/helpers.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
"""Defines helper functions used by Manifester."""
from collections import UserDict
import json
import os
from pathlib import Path
import random
import re
import subprocess
import sys
import time

from logzero import logger
from requests import HTTPError
import yaml

from manifester.logger import setup_logzero
from manifester.settings import settings

setup_logzero(level="info")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If switching to using the settings module, you can just use the configured log level



RESULTS_LIMIT = 10000


Expand Down Expand Up @@ -226,3 +235,139 @@ def __getitem__(self, key):
def __call__(self, *args, **kwargs):
"""Allow MockStub to be used like a function."""
return self


class InvalidVaultURLForOIDC(Exception):
"""Raised if the vault doesn't allow OIDC login."""


class Vault:
"""Helper class for retrieving secrets from HashiCorp Vault."""

HELP_TEXT = (
"The Vault CLI in not installed on this system."
"Please follow https://learn.hashicorp.com/tutorials/vault/getting-started-install to "
"install the Vault CLI."
)

def __init__(self, env_file=".env"):
manifester_directory = Path()

if "MANIFESTER_DIRECTORY" in os.environ:
envar_location = Path(os.environ["MANIFESTER_DIRECTORY"])
if envar_location.is_dir():
manifester_directory = envar_location
self.env_path = manifester_directory.joinpath(env_file)
Comment on lines +254 to +260
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These would likely be best done in a common place, like the settings module

Copy link
Collaborator Author

@synkd synkd May 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JacobCallahan This actually is done in the settings module, but I revisited this today and remembered why I had repeated it here: vault_login.py throws an error when trying to retrieve settings values if the user is not already logged in to Vault. If I'm understanding the situation correctly, vault_login.py imports helpers.py, in which it encounters an assignment that uses settings.get(), then dynaconf attempts to process the settings file, which includes a Vault-formatted setting (offline_token), and dynaconf subsequently tries to retrieve that value from Vault before the login process has completed. This is also why I had hard-coded the log level in this module, which you commented on as well.

There may be another way to work around this race condition, but I have not figured one out yet. Do you have any thoughts on an alternative, or alternatively, do you think my admittedly inelegant solution is acceptable?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm not ideal, but if it needs to be repeated anyway for the vault login then it's up to you whether that repetition happens here or in the vault script. I have no objections either way now.

self.envdata = None
self.vault_enabled = None

def setup(self):
"""Read environment variables from .env."""
if self.env_path.exists():
self.envdata = self.env_path.read_text()
is_enabled = re.findall("^(?:.*\n)*VAULT_ENABLED_FOR_DYNACONF=(.*)", self.envdata)
if is_enabled:
self.vault_enabled = is_enabled[0]
self.export_vault_addr()

def teardown(self):
"""Remove VAULT_ADDR environment variable if present."""
if os.environ.get("VAULT_ADDR") is not None:
del os.environ["VAULT_ADDR"]

def export_vault_addr(self):
"""Set the URL of the Vault server and ensure that the URL is not localhost."""
vaulturl = re.findall("VAULT_URL_FOR_DYNACONF=(.*)", self.envdata)[0]

# Set Vault CLI Env Var
os.environ["VAULT_ADDR"] = vaulturl

# Dynaconf Vault Env Vars
if (
self.vault_enabled
and self.vault_enabled in ["True", "true"]
and "localhost:8200" in vaulturl
):
raise InvalidVaultURLForOIDC(
f"{vaulturl} does not support OIDC login."
"Please set the correct vault URL vault the .env file."
)

def exec_vault_command(self, command: str, **kwargs):
"""Wrap Vault CLI commands for execution.

:param comamnd str: The vault CLI command
:param kwargs dict: Arguments to the subprocess run command to customize the run behavior
"""
COMMAND_NOT_FOUND_EXIT_CODE = 127
vcommand = subprocess.run(command.split(), capture_output=True, **kwargs)
if vcommand.returncode != 0:
verror = str(vcommand.stderr)
if vcommand.returncode == COMMAND_NOT_FOUND_EXIT_CODE:
logger.error(f"Error! {self.HELP_TEXT}")
sys.exit(1)
if vcommand.stderr:
if "Error revoking token" in verror:
logger.info("Token is already revoked")
elif "Error looking up token" in verror:
logger.info("Vault is not logged in")
else:
logger.error(f"Error: {verror}")
return vcommand

def login(self, **kwargs):
"""Authenticate to Vault server and add auth token to .env file."""
if (
self.vault_enabled
and self.vault_enabled in ["True", "true"]
and "VAULT_SECRET_ID_FOR_DYNACONF" not in os.environ
and self.status(**kwargs).returncode != 0
):
logger.info(
"Warning: A browser tab will open for Vault OIDC login. "
"Please close the tab once the sign-in is complete"
)
if (
self.exec_vault_command(command="vault login -method=oidc", **kwargs).returncode
== 0
):
self.exec_vault_command(command="vault token renew -i 10h", **kwargs)
logger.info("Success! Vault OIDC Logged-In and extended for 10 hours!")
# Fetch token
token = self.exec_vault_command("vault token lookup --format json").stdout
token = json.loads(str(token.decode("UTF-8")))["data"]["id"]
# Set new token in .env file
_envdata = re.sub(
".*VAULT_TOKEN_FOR_DYNACONF=.*",
f"VAULT_TOKEN_FOR_DYNACONF={token}",
self.envdata,
)
self.env_path.write_text(_envdata)
logger.info("New OIDC token succesfully added to .env file")

def logout(self):
"""Revoke Vault auth token and remove it from .env file."""
# Teardown - Setting dummy token in env file
_envdata = re.sub(
".*VAULT_TOKEN_FOR_DYNACONF=.*", "# VAULT_TOKEN_FOR_DYNACONF=myroot", self.envdata
)
self.env_path.write_text(_envdata)
vstatus = self.exec_vault_command("vault token revoke -self")
if vstatus.returncode == 0:
logger.info("OIDC token successfully removed from .env file")

def status(self, **kwargs):
"""Check status of Vault auth token."""
vstatus = self.exec_vault_command("vault token lookup", **kwargs)
if vstatus.returncode == 0:
logger.info(str(vstatus.stdout.decode("UTF-8")))
return vstatus

def __enter__(self):
"""Set up Vault context manager."""
self.setup()
return self

def __exit__(self, exc_type, exc_val, exc_tb):
"""Tear down Vault context manager."""
self.teardown()
3 changes: 1 addition & 2 deletions manifester/manifester.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@
from manifester.logger import setup_logzero
from manifester.settings import settings

setup_logzero(level=settings.get("log_level", "info"))


class Manifester:
"""Main Manifester class responsible for generating a manifest from the provided settings."""
Expand All @@ -35,6 +33,7 @@ def __init__(
proxies=None,
**kwargs,
):
setup_logzero(level=settings.get("log_level", "info"))
if minimal_init:
self.offline_token = settings.get("offline_token")
self.token_request_url = settings.get("url").get("token_request")
Expand Down
6 changes: 3 additions & 3 deletions manifester/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@

settings_path = MANIFESTER_DIRECTORY.joinpath("manifester_settings.yaml")
validators = [
# Validator("offline_token", must_exist=True),
Validator("offline_token", must_exist=True),
Validator("simple_content_access", default="enabled"),
Validator("username_prefix", len_min=3),
]
settings = Dynaconf(
settings_file=str(settings_path.absolute()),
ENVVAR_PREFIX_FOR_DYNACONF="MANIFESTER",
load_dotenv=True,
validators=validators,
)

settings.validators.validate()
# settings.validators.validate()
11 changes: 7 additions & 4 deletions manifester_settings.yaml.example
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
#rhsm-manifester settings
inventory_path: "manifester_inventory.yaml"
log_level: "info"
offline_token: ""
proxies: {"https": ""}
url:
token_request: "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token"
allocations: "https://api.access.redhat.com/management/v1/allocations"
username_prefix: "example_username" # replace value with a unique username
inventory_path: "manifester_inventory.yaml"
manifest_category:
golden_ticket:
# An offline token can be generated at https://access.redhat.com/management/api
offline_token: ""
# Value of sat_version setting should be in the form 'sat-6.10'
sat_version: "sat-6.10"
# Value of sat_version setting should be in the form 'sat-6.14'
sat_version: "sat-6.14"
# golden_ticket manifests should not use a quantity higher than 1 for any subscription
# unless doing so is required for a test.
subscription_data:
Expand All @@ -25,7 +28,7 @@ manifest_category:
proxies: {"https": ""}
robottelo_automation:
offline_token: ""
sat_version: "sat-6.10"
sat_version: "sat-6.14"
subscription_data:
- name: "Software Collections and Developer Toolset"
quantity: 3
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ classifiers = [
]
dependencies = [
"click",
"dynaconf",
"dynaconf[vault]",
"logzero",
"pytest",
"pyyaml",
Expand Down Expand Up @@ -156,6 +156,7 @@ ignore = [
"D407", # Section name underlining
"E731", # do not assign a lambda expression, use a def
"PLR0913", # Too many arguments to function call ({c_args} > {max_args})
"PLW1510", # subprocess.run without an explict `check` argument
"RUF012", # Mutable class attributes should be annotated with typing.ClassVar
"D107", # Missing docstring in __init__
]
Expand Down
14 changes: 14 additions & 0 deletions scripts/vault_login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env python
"""Enables and Disables an OIDC token to access secrets from HashiCorp Vault."""
import sys

from manifester.helpers import Vault

if __name__ == "__main__":
with Vault() as vclient:
if sys.argv[-1] == "--login":
vclient.login()
elif sys.argv[-1] == "--status":
vclient.status()
else:
vclient.logout()