Deployment Secrets (#301)

Allows environment variables to be used as secrets
This commit is contained in:
Sterling Dreyer 2025-03-17 21:17:17 -07:00 committed by GitHub
parent 7edaa0a996
commit f7f9489c3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 83 additions and 19 deletions

View file

@ -1,6 +1,7 @@
import base64 import base64
import io import io
import os import os
import re
import secrets import secrets
import tarfile import tarfile
from pathlib import Path from pathlib import Path
@ -11,7 +12,7 @@ import toml
from arcadepy import Arcade, NotFoundError from arcadepy import Arcade, NotFoundError
from httpx import Client from httpx import Client
from packaging.requirements import Requirement from packaging.requirements import Requirement
from pydantic import BaseModel, field_validator, model_validator from pydantic import BaseModel, field_serializer, field_validator, model_validator
# Base class for versioned packages # Base class for versioned packages
@ -63,26 +64,47 @@ class Pypi(PackageRepository):
trusted_host: str = "pypi.org" trusted_host: str = "pypi.org"
class Secret(BaseModel):
value: str
pattern: str | None = None
class Config(BaseModel): class Config(BaseModel):
id: str id: str
enabled: bool = True enabled: bool = True
timeout: int = 30 timeout: int = 30
retries: int = 3 retries: int = 3
secret: str secret: Secret | None = None
# Validate that the secret is a non-empty string and not 'dev' # Validate and parse the secret if required
@field_validator("secret") @field_validator("secret", mode="before")
@classmethod @classmethod
def valid_secret(cls, v: str) -> str: def valid_secret(cls, v: str | Secret | None) -> Secret:
if v.strip("") == "" or v == "dev": # If the secret is a string, attempt to parse it as an environment variable or return the secret
if isinstance(v, str):
secret = get_env_secret(v)
# If the secret has been manually set, return it
elif isinstance(v, Secret):
secret = v
else:
raise TypeError("Secret must be a string or a Secret object")
# Check that the secret is not the default dev secret or empty
if secret.value.strip() == "" or secret.value == "dev":
raise ValueError("Secret must be a non-empty string and not 'dev'") raise ValueError("Secret must be a non-empty string and not 'dev'")
return v return secret
@field_serializer("secret")
def serialize_secret(self, secret: Secret) -> str:
if secret.pattern:
return f"$env:{secret.pattern}"
else:
return secret.value
# Cloud request for deploying a worker # Cloud request for deploying a worker
class Request(BaseModel): class Request(BaseModel):
name: str name: str
secret: str secret: Secret
enabled: bool enabled: bool
timeout: int timeout: int
retries: int retries: int
@ -90,6 +112,10 @@ class Request(BaseModel):
custom_repositories: list[PackageRepository] | None = None custom_repositories: list[PackageRepository] | None = None
local_packages: list[LocalPackage] | None = None local_packages: list[LocalPackage] | None = None
@field_serializer("secret")
def serialize_secret(self, secret: Secret) -> str:
return secret.value
def execute(self, cloud_client: Client, engine_client: Arcade) -> Any: def execute(self, cloud_client: Client, engine_client: Arcade) -> Any:
# Attempt to deploy worker to the cloud # Attempt to deploy worker to the cloud
try: try:
@ -113,7 +139,7 @@ class Request(BaseModel):
enabled=self.enabled, enabled=self.enabled,
http={ http={
"uri": cloud_response.json()["data"]["worker_endpoint"], "uri": cloud_response.json()["data"]["worker_endpoint"],
"secret": self.secret, "secret": self.secret.value,
"timeout": self.timeout, "timeout": self.timeout,
"retry": self.retries, "retry": self.retries,
}, },
@ -125,7 +151,7 @@ class Request(BaseModel):
enabled=self.enabled, enabled=self.enabled,
http={ http={
"uri": cloud_response.json()["data"]["worker_endpoint"], "uri": cloud_response.json()["data"]["worker_endpoint"],
"secret": self.secret, "secret": self.secret.value,
"timeout": self.timeout, "timeout": self.timeout,
"retry": self.retries, "retry": self.retries,
}, },
@ -148,6 +174,8 @@ class Worker(BaseModel):
"""Convert Deployment to a Request object.""" """Convert Deployment to a Request object."""
self.validate_packages() self.validate_packages()
self.compress_local_packages() self.compress_local_packages()
if self.config.secret is None:
raise ValueError("Secret is required")
return Request( return Request(
name=self.config.id, name=self.config.id,
secret=self.config.secret, secret=self.config.secret,
@ -274,7 +302,7 @@ def create_demo_deployment(toml_path: Path, toolkit_name: str) -> None:
enabled=True, enabled=True,
timeout=30, timeout=30,
retries=3, retries=3,
secret=secrets.token_hex(16), secret=Secret(value=secrets.token_hex(16), pattern=None),
), ),
local_source=LocalPackages(packages=[f"./{toolkit_name}"]), local_source=LocalPackages(packages=[f"./{toolkit_name}"]),
) )
@ -292,3 +320,25 @@ def update_deployment_with_local_packages(toml_path: Path, toolkit_name: str) ->
else: else:
deployment.worker[0].local_source.packages.append(f"./{toolkit_name}") deployment.worker[0].local_source.packages.append(f"./{toolkit_name}")
deployment.save() deployment.save()
def get_env_secret(secret: str) -> Secret:
"""Parse a secret from an environment variable."""
# Check if the secret contains the "${env:}" syntax
pattern = r"\${env:([^}]+)}"
matches = re.findall(pattern, secret)
# Only allow a single match
if matches and len(matches) == 1:
match = matches[0].strip()
# Attempt to lookup and create the secret
print(f"Looking up secret: {match}")
value = os.getenv(match)
if value:
return Secret(value=value, pattern=match)
else:
raise ValueError(f"Environment variable not found: {match}")
elif matches and len(matches) > 1:
raise ValueError(f"Multiple environment variables found in secret: {secret}")
# If no matches are found, return the secret as is
return Secret(value=secret, pattern=None)

View file

@ -2,6 +2,7 @@
# ruff: noqa: S105 # ruff: noqa: S105
# ruff: noqa: S106 # ruff: noqa: S106
import json import json
import os
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -13,6 +14,7 @@ from arcade.worker.config.deployment import (
Package, Package,
PackageRepository, PackageRepository,
Pypi, Pypi,
Secret,
Worker, Worker,
) )
@ -41,7 +43,7 @@ def test_deployment_parsing(test_dir):
assert deployment.worker[0].config.enabled is True assert deployment.worker[0].config.enabled is True
assert deployment.worker[0].config.timeout == 10 assert deployment.worker[0].config.timeout == 10
assert deployment.worker[0].config.retries == 3 assert deployment.worker[0].config.retries == 3
assert deployment.worker[0].config.secret == "test-secret" assert deployment.worker[0].config.secret == Secret(value="test-secret", pattern=None)
# Test pypi section # Test pypi section
assert deployment.worker[0].pypi_source.packages == [Package(name="arcade-x")] assert deployment.worker[0].pypi_source.packages == [Package(name="arcade-x")]
@ -123,7 +125,6 @@ def test_deployment_dict(test_dir):
] ]
}""") }""")
got = deployment.worker[0].request().model_dump(mode="json") got = deployment.worker[0].request().model_dump(mode="json")
print(got)
# Remove encoding part that contains the content # Remove encoding part that contains the content
got["local_packages"][0].pop("content") got["local_packages"][0].pop("content")
expected["local_packages"][0].pop("content") expected["local_packages"][0].pop("content")
@ -161,7 +162,7 @@ def test_unconfigured_local_package(test_dir):
def test_duplicate_pypi_packages(): def test_duplicate_pypi_packages():
worker = Worker( worker = Worker(
toml_path=Path(__file__), toml_path=Path(__file__),
config=Config(id="test", secret="test-secret"), config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
pypi_source=Pypi(packages=["arcade-slack", "arcade-slack"]), pypi_source=Pypi(packages=["arcade-slack", "arcade-slack"]),
) )
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -171,7 +172,7 @@ def test_duplicate_pypi_packages():
def test_duplicate_custom_repository_packages(): def test_duplicate_custom_repository_packages():
worker = Worker( worker = Worker(
toml_path=Path(__file__), toml_path=Path(__file__),
config=Config(id="test", secret="test-secret"), config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
custom_source=[ custom_source=[
PackageRepository( PackageRepository(
index="pypi", index="pypi",
@ -188,7 +189,7 @@ def test_duplicate_custom_repository_packages():
def test_duplicate_local_packages(): def test_duplicate_local_packages():
worker = Worker( worker = Worker(
toml_path=Path(__file__), toml_path=Path(__file__),
config=Config(id="test", secret="test-secret"), config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
local_source=LocalPackages(packages=["./mock_toolkit", "./mock_toolkit"]), local_source=LocalPackages(packages=["./mock_toolkit", "./mock_toolkit"]),
) )
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -198,7 +199,7 @@ def test_duplicate_local_packages():
def test_duplicate_all_typed_packages(): def test_duplicate_all_typed_packages():
worker = Worker( worker = Worker(
toml_path=Path(__file__), toml_path=Path(__file__),
config=Config(id="test", secret="test-secret"), config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
pypi_source=Pypi(packages=["arcade-slack"]), pypi_source=Pypi(packages=["arcade-slack"]),
custom_source=[ custom_source=[
PackageRepository( PackageRepository(
@ -217,11 +218,19 @@ def test_duplicate_all_typed_packages():
def test_duplicate_worker_names(): def test_duplicate_worker_names():
worker = Worker( worker = Worker(
toml_path=Path(__file__), toml_path=Path(__file__),
config=Config(id="test", secret="test-secret"), config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
) )
worker2 = Worker( worker2 = Worker(
toml_path=Path(__file__), toml_path=Path(__file__),
config=Config(id="test", secret="test-secret"), config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
) )
with pytest.raises(ValueError): with pytest.raises(ValueError):
Deployment(workers=[worker, worker2]) Deployment(workers=[worker, worker2])
def test_secret_parsing(test_dir):
os.environ["TEST_WORKER_SECRET"] = "test-secret"
deployment = Deployment.from_toml(test_dir / "test_files" / "env.secret.worker.toml")
assert deployment.worker[0].config.secret == Secret(
value="test-secret", pattern="TEST_WORKER_SECRET"
)

View file

@ -0,0 +1,5 @@
[[worker]]
[worker.config]
id = "test"
secret = "${env: TEST_WORKER_SECRET}"