chore: migration 1

This commit is contained in:
Will Fu-Hinthorn
2026-04-08 04:46:31 -07:00
parent 98fd216ce7
commit 8e89c8b155
20 changed files with 6684 additions and 6 deletions
+272
View File
@@ -0,0 +1,272 @@
// Package deploy provides an HTTP client for the LangGraph host backend
// deployment service.
package deploy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Secret represents a name/value pair sent as a deployment secret.
type Secret struct {
Name string `json:"name"`
Value string `json:"value"`
}
// HostBackendClient is a minimal JSON HTTP client for the host backend
// deployment service.
type HostBackendClient struct {
BaseURL string
APIKey string
TenantID string
client *http.Client
}
// retryTransport wraps an http.RoundTripper and retries failed requests.
type retryTransport struct {
base http.RoundTripper
retries int
}
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var resp *http.Response
var err error
// We need to buffer the body so we can replay it on retries.
var bodyBytes []byte
if req.Body != nil {
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return nil, err
}
req.Body.Close()
}
for attempt := 0; attempt <= t.retries; attempt++ {
if bodyBytes != nil {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
resp, err = t.base.RoundTrip(req)
if err == nil {
return resp, nil
}
// Only retry on transport-level errors; do not retry on HTTP error
// status codes (the caller handles those).
}
return resp, err
}
// NewClient creates a new HostBackendClient. The baseURL is stripped of any
// trailing slash. The underlying http.Client uses a 30-second timeout and
// retries transport-level failures up to 3 times.
func NewClient(baseURL, apiKey string) *HostBackendClient {
return &HostBackendClient{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
TenantID: "",
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &retryTransport{
base: http.DefaultTransport,
retries: 3,
},
},
}
}
// request executes an HTTP request against the host backend and returns the
// parsed JSON response. It attaches required headers and handles errors.
func (c *HostBackendClient) request(method, path string, payload map[string]any, params map[string]string) (map[string]any, error) {
fullURL := c.BaseURL + path
// Append query parameters.
if len(params) > 0 {
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
fullURL += "?" + q.Encode()
}
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshalling request payload: %w", err)
}
body = bytes.NewReader(data)
}
req, err := http.NewRequest(method, fullURL, body)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", c.APIKey)
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.TenantID != "" {
req.Header.Set("X-Tenant-ID", c.TenantID)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response from %s: %w", path, err)
}
if resp.StatusCode >= 400 {
detail := string(respBody)
if detail == "" {
detail = fmt.Sprintf("%d", resp.StatusCode)
}
return nil, fmt.Errorf("%s %s failed with status %d: %s", method, path, resp.StatusCode, detail)
}
if len(respBody) == 0 {
return nil, nil
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to decode response from %s: %w", path, err)
}
return result, nil
}
// requestNoBody is a convenience wrapper for requests that return no parsed body.
func (c *HostBackendClient) requestNoBody(method, path string, payload map[string]any, params map[string]string) error {
_, err := c.request(method, path, payload, params)
return err
}
// CreateDeployment creates a new deployment.
func (c *HostBackendClient) CreateDeployment(name, deploymentType, source string, configPath string, secrets []Secret) (map[string]any, error) {
sourceRevisionConfig := map[string]any{}
if source == "internal_source" && configPath != "" {
sourceRevisionConfig["langgraph_config_path"] = configPath
}
payload := map[string]any{
"name": name,
"source": source,
"source_config": map[string]any{"deployment_type": deploymentType},
"source_revision_config": sourceRevisionConfig,
}
if secrets != nil {
payload["secrets"] = secrets
}
return c.request("POST", "/v2/deployments", payload, nil)
}
// ListDeployments lists deployments, optionally filtering by name.
func (c *HostBackendClient) ListDeployments(nameContains string) (map[string]any, error) {
params := map[string]string{"name_contains": nameContains}
return c.request("GET", "/v2/deployments", nil, params)
}
// GetDeployment retrieves a single deployment by ID.
func (c *HostBackendClient) GetDeployment(deploymentID string) (map[string]any, error) {
return c.request("GET", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil)
}
// DeleteDeployment deletes a deployment by ID.
func (c *HostBackendClient) DeleteDeployment(deploymentID string) error {
return c.requestNoBody("DELETE", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil)
}
// RequestPushToken requests a push token for a deployment.
func (c *HostBackendClient) RequestPushToken(deploymentID string) (map[string]any, error) {
return c.request("POST", fmt.Sprintf("/v2/deployments/%s/push-token", deploymentID), nil, nil)
}
// RequestUploadURL gets a signed URL for uploading the source tarball.
func (c *HostBackendClient) RequestUploadURL(deploymentID string) (map[string]any, error) {
return c.request("POST", fmt.Sprintf("/v2/deployments/%s/upload-url", deploymentID), nil, nil)
}
// UpdateDeployment triggers a new revision using a pre-pushed Docker image.
func (c *HostBackendClient) UpdateDeployment(deploymentID, imageURI string, secrets []Secret) (map[string]any, error) {
payload := map[string]any{
"revision_source": "internal_docker",
"source_revision_config": map[string]any{"image_uri": imageURI},
}
if secrets != nil {
payload["secrets"] = secrets
}
return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil)
}
// UpdateDeploymentInternalSource triggers a remote-build revision using an
// uploaded source tarball.
func (c *HostBackendClient) UpdateDeploymentInternalSource(
deploymentID, sourceTarballPath, configPath string,
secrets []Secret,
installCommand, buildCommand string,
) (map[string]any, error) {
payload := map[string]any{
"revision_source": "internal_source",
"source_revision_config": map[string]any{
"source_tarball_path": sourceTarballPath,
"langgraph_config_path": configPath,
},
}
sourceConfig := map[string]any{}
if installCommand != "" {
sourceConfig["install_command"] = installCommand
}
if buildCommand != "" {
sourceConfig["build_command"] = buildCommand
}
if len(sourceConfig) > 0 {
payload["source_config"] = sourceConfig
}
if secrets != nil {
payload["secrets"] = secrets
}
return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil)
}
// ListRevisions lists revisions for a deployment.
func (c *HostBackendClient) ListRevisions(deploymentID string, limit int) (map[string]any, error) {
return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions", deploymentID), nil, map[string]string{
"limit": fmt.Sprintf("%d", limit),
})
}
// GetRevision retrieves a single revision.
func (c *HostBackendClient) GetRevision(deploymentID, revisionID string) (map[string]any, error) {
return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions/%s", deploymentID, revisionID), nil, nil)
}
// GetBuildLogs retrieves build logs for a revision.
func (c *HostBackendClient) GetBuildLogs(projectID, revisionID string, payload map[string]any) (map[string]any, error) {
return c.request("POST", fmt.Sprintf("/v1/projects/%s/revisions/%s/build_logs", projectID, revisionID), payload, nil)
}
// GetDeployLogs retrieves deploy logs. If revisionID is non-empty, it is
// included in the path to scope the logs.
func (c *HostBackendClient) GetDeployLogs(projectID string, payload map[string]any, revisionID string) (map[string]any, error) {
var path string
if revisionID != "" {
path = fmt.Sprintf("/v1/projects/%s/revisions/%s/deploy_logs", projectID, revisionID)
} else {
path = fmt.Sprintf("/v1/projects/%s/deploy_logs", projectID)
}
return c.request("POST", path, payload, nil)
}
+268
View File
@@ -0,0 +1,268 @@
package deploy
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// APIKeyEnvNames lists the environment variable names checked (in order) when
// resolving a LangSmith / LangGraph API key.
var APIKeyEnvNames = []string{
"LANGGRAPH_HOST_API_KEY",
"LANGSMITH_API_KEY",
"LANGCHAIN_API_KEY",
}
// DefaultHostURL is the default host backend URL.
const DefaultHostURL = "https://api.host.langchain.com"
// reservedEnvVars contains environment variable names that must not be sent as
// deployment secrets. The set mirrors the Python CLI's RESERVED_ENV_VARS.
var reservedEnvVars = map[string]bool{
// LANGCHAIN_RESERVED_ENV_VARS from host-backend
"LANGCHAIN_TRACING_V2": true,
"LANGSMITH_TRACING_V2": true,
"LANGCHAIN_ENDPOINT": true,
"LANGCHAIN_PROJECT": true,
"LANGSMITH_PROJECT": true,
"LANGSMITH_LANGGRAPH_GIT_REPO": true,
"LANGGRAPH_GIT_REPO_PATH": true,
"LANGCHAIN_API_KEY": true,
"LANGSMITH_CONTROL_PLANE_API_KEY": true,
"POSTGRES_URI": true,
"POSTGRES_PASSWORD": true,
"DATABASE_URI": true,
"LANGSMITH_LANGGRAPH_GIT_REF": true,
"LANGSMITH_LANGGRAPH_GIT_REF_SHA": true,
"LANGGRAPH_AUTH_TYPE": true,
"LANGSMITH_AUTH_ENDPOINT": true,
"LANGSMITH_TENANT_ID": true,
"LANGSMITH_AUTH_VERIFY_TENANT_ID": true,
"LANGSMITH_HOST_PROJECT_ID": true,
"LANGSMITH_HOST_PROJECT_NAME": true,
"LANGSMITH_HOST_REVISION_ID": true,
"LOG_JSON": true,
"LOG_DICT_TRACEBACKS": true,
"REDIS_URI": true,
"LANGCHAIN_CALLBACKS_BACKGROUND": true,
"DD_TRACE_PSYCOPG_ENABLED": true,
"DD_TRACE_REDIS_ENABLED": true,
"LANGSMITH_DEPLOYMENT_NAME": true,
"LANGGRAPH_CLOUD_LICENSE_KEY": true,
// ALLOWED_SELF_HOSTED_ENV_VARS (rejected for non-self-hosted)
"LANGSMITH_API_KEY": true,
"LANGSMITH_ENDPOINT": true,
"POSTGRES_URI_CUSTOM": true,
"REDIS_URI_CUSTOM": true,
"PATH": true,
"PORT": true,
"MOUNT_PREFIX": true,
"LSD_ENV": true,
"LSD_DD_API_KEY": true,
"LSD_DD_ENDPOINT": true,
"LSD_DEPLOYMENT_TYPE": true,
}
var (
invalidImageNameChars = regexp.MustCompile(`[^a-z0-9._-]+`)
validImageTag = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
)
// NormalizeImageName sanitizes a deployment/directory name into a valid Docker
// repository name. Invalid characters are replaced with hyphens and the result
// is lowercased. Returns "app" if the result would be empty.
func NormalizeImageName(name string) string {
if name == "" {
return "app"
}
slug := invalidImageNameChars.ReplaceAllString(strings.ToLower(name), "-")
slug = strings.TrimLeft(slug, "-.")
slug = strings.TrimRight(slug, "-.")
if slug == "" {
return "app"
}
return slug
}
// NormalizeImageTag validates and returns a Docker image tag. Tags may only
// contain [A-Za-z0-9_.-]. Defaults to "latest" when empty.
func NormalizeImageTag(tag string) (string, error) {
if tag == "" {
return "latest", nil
}
if !validImageTag.MatchString(tag) {
return "", fmt.Errorf("image tag may only contain characters A-Z, a-z, 0-9, '_', '-', '.'")
}
return tag, nil
}
// ResolveAPIKey resolves an API key by checking (in order): the explicit flag
// value, the provided envVars map, and the process environment. Returns an
// empty string if no key is found (the caller should prompt the user).
func ResolveAPIKey(flagValue string, envVars map[string]string) string {
if flagValue != "" {
return flagValue
}
for _, keyName := range APIKeyEnvNames {
if envVars != nil {
if v, ok := envVars[keyName]; ok && v != "" {
return v
}
}
if v := os.Getenv(keyName); v != "" {
return v
}
}
return ""
}
// ParseEnvFromConfig resolves environment variables from the langgraph.json
// config. If the "env" field is a dict (map), those values are used directly.
// If it is a string, it is treated as a path to a .env file (resolved relative
// to the config file's directory). Otherwise, a .env file in the config
// directory is attempted as a fallback.
func ParseEnvFromConfig(configJSON map[string]any, configPath string) map[string]string {
envField, ok := configJSON["env"]
if !ok {
// Fallback: try .env in config dir.
return parseDotEnvFile(filepath.Join(filepath.Dir(configPath), ".env"))
}
// If env is a dict (map[string]any), convert to map[string]string.
if envMap, ok := envField.(map[string]any); ok && len(envMap) > 0 {
result := make(map[string]string, len(envMap))
for k, v := range envMap {
result[k] = fmt.Sprintf("%v", v)
}
return result
}
// If env is a string path, parse that .env file.
if envStr, ok := envField.(string); ok && envStr != "" {
envPath := filepath.Join(filepath.Dir(configPath), envStr)
absPath, err := filepath.Abs(envPath)
if err != nil {
return map[string]string{}
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
return map[string]string{}
}
return parseDotEnvFile(absPath)
}
// Fallback: try .env in config dir.
return parseDotEnvFile(filepath.Join(filepath.Dir(configPath), ".env"))
}
// parseDotEnvFile reads a .env file and returns its key-value pairs. Lines
// starting with # are treated as comments. Empty values are skipped.
func parseDotEnvFile(path string) map[string]string {
f, err := os.Open(path)
if err != nil {
return map[string]string{}
}
defer f.Close()
result := map[string]string{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
// Strip surrounding quotes if present.
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
if key != "" && value != "" {
result[key] = value
}
}
return result
}
// FindDeploymentIDByName lists deployments matching the given name and returns
// the ID of the first exact match. Returns an empty string (and no error) if
// no exact match is found.
func FindDeploymentIDByName(client *HostBackendClient, name string) (string, error) {
if name == "" {
return "", nil
}
existing, err := client.ListDeployments(name)
if err != nil {
return "", err
}
resources, ok := existing["resources"]
if !ok {
return "", nil
}
resourceList, ok := resources.([]any)
if !ok {
return "", nil
}
for _, item := range resourceList {
dep, ok := item.(map[string]any)
if !ok {
continue
}
depName, _ := dep["name"].(string)
if depName == name {
if id, ok := dep["id"]; ok {
return fmt.Sprintf("%v", id), nil
}
}
}
return "", nil
}
// ValidateDeploymentSelector ensures at least one of deploymentID or name is
// provided.
func ValidateDeploymentSelector(deploymentID, name string) error {
if deploymentID != "" {
return nil
}
if name == "" {
return fmt.Errorf("either --deployment-id or --name is required")
}
return nil
}
// ResolvedReservedEnvVars returns the set of reserved environment variable
// names that must not be sent as deployment secrets.
func ResolvedReservedEnvVars() map[string]bool {
// Return a copy to prevent callers from mutating the package-level map.
result := make(map[string]bool, len(reservedEnvVars))
for k, v := range reservedEnvVars {
result[k] = v
}
return result
}
// SecretsFromEnv converts an env var map into a Secret slice, filtering out
// reserved variable names and empty values.
func SecretsFromEnv(envVars map[string]string) []Secret {
var secrets []Secret
for name, value := range envVars {
if reservedEnvVars[name] {
continue
}
if value == "" {
continue
}
secrets = append(secrets, Secret{Name: name, Value: value})
}
return secrets
}