Skip to content

Commit

Permalink
feat: hashicorp nomad integration (#253)
Browse files Browse the repository at this point in the history
* chore: bump react icons version

* feat: add nomad service config

* feat: add icon and docs link for nomad

* feat: add service config for nomad

* feat: add syncing utils for nomad

* feat: add worker job code for nomad sync

* feat: add query and mutation resolvers to nomad syncs

* feat: add frontend queries and mutations

* chore: regenerate graphql schema and types

* feat: add dialog to setup nomad sync

* feat: allow updating optional fields for sync auth

* fix: move namespace to sync config, update auth test url

* fix: add field for namespace in sync setup, add loading state when testing auth

* fix: revert change to credential management dialog

* fix: credential valid state

* fix: rename nomad token var

* fix: nomad_token_secret dict key

* fix: update create nomad sync mutation args

* fix: error message for no secrets

* fix: menu overflow

* feat: add namespace to service info
  • Loading branch information
rohan-chaturvedi authored May 14, 2024
1 parent 7bf0409 commit ac09473
Show file tree
Hide file tree
Showing 22 changed files with 630 additions and 22 deletions.
18 changes: 18 additions & 0 deletions backend/api/migrations/0064_alter_providercredentials_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-05-13 09:16

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0063_lockbox'),
]

operations = [
migrations.AlterField(
model_name='providercredentials',
name='provider',
field=models.CharField(choices=[('cloudflare', 'Cloudflare'), ('aws', 'AWS'), ('github', 'GitHub'), ('hashicorp_vault', 'Hashicorp Vault'), ('hashicorp_domad', 'Hashicorp Nomad')], max_length=50),
),
]
18 changes: 18 additions & 0 deletions backend/api/migrations/0065_alter_providercredentials_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-05-13 09:19

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0064_alter_providercredentials_provider'),
]

operations = [
migrations.AlterField(
model_name='providercredentials',
name='provider',
field=models.CharField(choices=[('cloudflare', 'Cloudflare'), ('aws', 'AWS'), ('github', 'GitHub'), ('hashicorp_vault', 'Hashicorp Vault'), ('hashicorp_nomad', 'Hashicorp Nomad')], max_length=50),
),
]
18 changes: 18 additions & 0 deletions backend/api/migrations/0066_alter_environmentsync_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-05-13 12:09

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0065_alter_providercredentials_provider'),
]

operations = [
migrations.AlterField(
model_name='environmentsync',
name='service',
field=models.CharField(choices=[('cloudflare_pages', 'Cloudflare Pages'), ('aws_secrets_manager', 'AWS Secrets Manager'), ('github_actions', 'GitHub Actions'), ('hashicorp_vault', 'Hashicorp Vault'), ('hashicorp_nomad', 'Hashicorp Nomad')], max_length=50),
),
]
18 changes: 18 additions & 0 deletions backend/api/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ class Providers:
"auth_scheme": "token",
}

HASHICORP_NOMAD = {
"id": "hashicorp_nomad",
"name": "Hashicorp Nomad",
"expected_credentials": [
"nomad_addr",
"nomad_token_secret",
],
"optional_credentials": [],
"auth_scheme": "token",
}

@classmethod
def get_provider_choices(cls):
return [
Expand Down Expand Up @@ -86,6 +97,13 @@ class ServiceConfig:
"resource_type": "path",
}

HASHICORP_NOMAD = {
"id": "hashicorp_nomad",
"name": "Hashicorp Nomad",
"provider": Providers.HASHICORP_NOMAD,
"resource_type": "path",
}

@classmethod
def get_service_choices(cls):
return [
Expand Down
89 changes: 89 additions & 0 deletions backend/api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
sync_github_secrets,
)
from api.utils.syncing.vault.main import sync_vault_secrets
from api.utils.syncing.nomad.main import sync_nomad_secrets
from .utils.syncing.cloudflare.pages import (
get_cf_pages_credentials,
sync_cloudflare_secrets,
Expand Down Expand Up @@ -63,6 +64,15 @@ def trigger_sync_tasks(env_sync):

EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync)

elif env_sync.service == ServiceConfig.HASHICORP_NOMAD["id"]:
env_sync.status = EnvironmentSync.IN_PROGRESS
env_sync.save()

job = perform_nomad_sync.delay(env_sync)
job_id = job.get_id()

EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync)


# try and cancel running or queued jobs for this sync
def cancel_sync_tasks(env_sync):
Expand Down Expand Up @@ -483,3 +493,82 @@ def perform_vault_sync(environment_sync):
environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.FAILED
environment_sync.save()


@job("default", timeout=3600)
def perform_nomad_sync(environment_sync):
try:
EnvironmentSync = apps.get_model("api", "EnvironmentSync")
EnvironmentSyncEvent = apps.get_model("api", "EnvironmentSyncEvent")

sync_event = (
EnvironmentSyncEvent.objects.filter(env_sync=environment_sync)
.order_by("-created_at")
.first()
)

kv_pairs = get_environment_secrets(
environment_sync.environment, environment_sync.path
)

if environment_sync.authentication is None:
sync_data = (
False,
{"message": "No authentication credentials for this sync"},
)
raise Exception("No authentication credentials for this sync")

project_info = environment_sync.options

success, sync_data = sync_nomad_secrets(
kv_pairs,
environment_sync.authentication.id,
project_info.get("path"),
project_info.get("namespace"),
)

if success:
sync_event.status = EnvironmentSync.COMPLETED
sync_event.completed_at = timezone.now()
sync_event.meta = sync_data
sync_event.save()

environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.COMPLETED
environment_sync.save()

else:
sync_event.status = EnvironmentSync.FAILED
sync_event.completed_at = timezone.now()
sync_event.meta = sync_data
sync_event.save()

environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.FAILED
environment_sync.save()

except JobTimeoutException:
# Handle timeout exception
sync_event.status = EnvironmentSync.TIMED_OUT
sync_event.completed_at = timezone.now()
sync_event.save()

environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.TIMED_OUT
environment_sync.save()
raise # Re-raise the JobTimeoutException

except Exception as ex:
print(f"EXCEPTION {ex}")
sync_event.status = EnvironmentSync.FAILED
sync_event.completed_at = timezone.now()

try:
sync_event.meta = sync_data
except:
pass
sync_event.save()

environment_sync.last_sync = timezone.now()
environment_sync.status = EnvironmentSync.FAILED
environment_sync.save()
92 changes: 92 additions & 0 deletions backend/api/utils/syncing/nomad/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from api.utils.syncing.auth import get_credentials
import requests
import re


def get_nomad_token_info(credential_id):
"""Get info for a given nomad token."""

credentials = get_credentials(credential_id)

NOMAD_ADDR = credentials["nomad_addr"]
NOMAD_TOKEN = credentials["nomad_token_secret"]

session = requests.Session()
session.headers.update(
{
"Authorization": f"Bearer {NOMAD_TOKEN}",
"Content-Type": "application/json",
}
)

url = f"{NOMAD_ADDR}/v1/acl/token/self"
response = session.get(url)
response.raise_for_status()
return response.json()


def test_nomad_creds(credential_id):
"""Test Nomad credentials by attempting to get token info."""
try:
get_nomad_token_info(credential_id)
return True
except requests.HTTPError as e:
return False


def sync_nomad_secrets(secrets, credential_id, path, namespace="default"):
results = {}

if not secrets or len(secrets) == 0:
results["error"] = "Error: No secrets to sync."
return False, results

try:
secrets_dict = dict(secrets)

# Regex to validate the path
path_regex = re.compile(r"^[a-zA-Z0-9-_~/]{1,128}$")

# Normalize and check the path
safe_path = path.strip("/").replace("//", "/")
if not path_regex.match(safe_path):
raise ValueError(
f"Invalid path: {safe_path}. Path must match the pattern [a-zA-Z0-9-_~/]{{1,128}}."
)

credentials = get_credentials(credential_id)

NOMAD_ADDR = credentials["nomad_addr"]
NOMAD_TOKEN = credentials["nomad_token_secret"]

session = requests.Session()
session.headers.update(
{
"Authorization": f"Bearer {NOMAD_TOKEN}",
"Content-Type": "application/json",
}
)

url = f"{NOMAD_ADDR}/v1/var/{safe_path}?namespace={namespace}"

# All secrets are included under the 'Items' field in the payload
payload = {
"Namespace": namespace,
"Path": safe_path,
"Items": secrets_dict,
}

response = session.put(url, json=payload)

response.raise_for_status()

success = True
results["message"] = (
f"All secrets successfully synced to Nomad at path: {safe_path} in namespace: {namespace}."
)

except Exception as e:
success = False
results["error"] = f"An error occurred: {str(e)}"

return success, results
50 changes: 49 additions & 1 deletion backend/backend/graphene/mutations/syncing.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ def mutate(cls, root, info, env_id, path, credential_id, engine, vault_path):

for es in existing_syncs:
if es.options == sync_options:
raise GraphQLError("A sync already exists for this GitHub repo!")
raise GraphQLError("A sync already exists for this Vault path!")

sync = EnvironmentSync.objects.create(
environment=env,
Expand All @@ -317,6 +317,54 @@ def mutate(cls, root, info, env_id, path, credential_id, engine, vault_path):
return CreateVaultSync(sync=sync)


class CreateNomadSync(graphene.Mutation):
class Arguments:
env_id = graphene.ID()
path = graphene.String()
credential_id = graphene.ID()
nomad_path = graphene.String()
nomad_namespace = graphene.String()

sync = graphene.Field(EnvironmentSyncType)

@classmethod
def mutate(
cls, root, info, env_id, path, credential_id, nomad_path, nomad_namespace
):
service_id = "hashicorp_nomad"
service_config = ServiceConfig.get_service_config(service_id)

env = Environment.objects.get(id=env_id)

if not ServerEnvironmentKey.objects.filter(environment=env).exists():
raise GraphQLError("Syncing is not enabled for this environment!")

if not user_can_access_app(info.context.user.userId, env.app.id):
raise GraphQLError("You don't have access to this app")

sync_options = {"path": nomad_path, "namespace": nomad_namespace}

existing_syncs = EnvironmentSync.objects.filter(
environment__app_id=env.app.id, service=service_id, deleted_at=None
)

for es in existing_syncs:
if es.options == sync_options:
raise GraphQLError("A sync already exists for this Nomad path!")

sync = EnvironmentSync.objects.create(
environment=env,
path=normalize_path_string(path),
service=service_id,
options=sync_options,
authentication_id=credential_id,
)

trigger_sync_tasks(sync)

return CreateNomadSync(sync=sync)


class DeleteSync(graphene.Mutation):
class Arguments:
sync_id = graphene.ID()
Expand Down
9 changes: 9 additions & 0 deletions backend/backend/graphene/queries/syncing.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from api.utils.syncing.aws.secrets_manager import list_aws_secrets
from api.utils.syncing.github.actions import list_repos
from api.utils.syncing.vault.main import test_vault_creds
from api.utils.syncing.nomad.main import test_nomad_creds
from backend.graphene.types import ProviderType, ServiceType
from graphql import GraphQLError

Expand Down Expand Up @@ -119,6 +120,14 @@ def resolve_test_vault_creds(root, info, credential_id):
raise GraphQLError(f"Error testing Vault credentials: {str(ex)}")


def resolve_test_nomad_creds(root, info, credential_id):
try:
valid = test_nomad_creds(credential_id)
return valid
except Exception as ex:
raise GraphQLError(f"Error testing Nomad credentials: {str(ex)}")


def resolve_syncs(root, info, app_id=None, env_id=None, org_id=None):
# If both app_id and env_id are provided
if app_id and env_id:
Expand Down
Loading

0 comments on commit ac09473

Please sign in to comment.