854 lines
26 KiB
Bash
Executable File
854 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REGISTRY_TOOL="${SCRIPT_DIR}/instance-registry.py"
|
|
|
|
IMAGE_NAME="${IMAGE_NAME:-beaver/app-instance:latest}"
|
|
INSTANCES_ROOT_DEFAULT="${SCRIPT_DIR}/runtime/instances"
|
|
REGISTRY_PATH_DEFAULT="${SCRIPT_DIR}/runtime/registry/instances.json"
|
|
KNOWN_PROVIDERS=" custom anthropic openai openrouter deepseek groq zhipu dashscope vllm gemini moonshot minimax aihubmix siliconflow volcengine "
|
|
|
|
INSTANCE_ID=""
|
|
INSTANCE_SLUG=""
|
|
CONTAINER_NAME=""
|
|
HOST_PORT=""
|
|
PUBLIC_URL=""
|
|
AUTHZ_BASE_URL=""
|
|
AUTHZ_INTERNAL_TOKEN=""
|
|
AUTHZ_OUTLOOK_MCP_URL=""
|
|
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
|
|
USER_FILES_MAX_UPLOAD_BYTES="${USER_FILES_MAX_UPLOAD_BYTES:-}"
|
|
EXTERNAL_CONNECTOR_BASE_URL="${EXTERNAL_CONNECTOR_BASE_URL:-http://external-connector:8787}"
|
|
EXTERNAL_CONNECTOR_TOKEN="${EXTERNAL_CONNECTOR_TOKEN:-}"
|
|
BEAVER_BRIDGE_TOKEN="${BEAVER_BRIDGE_TOKEN:-}"
|
|
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="${EXTERNAL_CONNECTOR_CALLBACK_BASE_URL:-}"
|
|
BACKEND_ID=""
|
|
CLIENT_ID=""
|
|
CLIENT_SECRET=""
|
|
BACKEND_NAME=""
|
|
MODEL="openai/gpt-5"
|
|
PROVIDER="openai"
|
|
API_KEY="${API_KEY:-}"
|
|
API_BASE="${API_BASE:-}"
|
|
SKIP_PROVIDER_CONFIG=0
|
|
AUTH_USERNAME=""
|
|
AUTH_PASSWORD=""
|
|
USERNAME=""
|
|
EMAIL=""
|
|
INSTANCE_HOST=""
|
|
AUTH_PORTAL_URL="${AUTH_PORTAL_URL:-}"
|
|
AUTH_PORTAL_PORT="${AUTH_PORTAL_PORT:-3081}"
|
|
INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}"
|
|
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
|
|
NETWORK_NAME="${NETWORK_NAME:-}"
|
|
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
|
|
INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}"
|
|
INITIAL_SKILLS_EXCLUDE="${INITIAL_SKILLS_EXCLUDE:-officebench-mcp}"
|
|
SEED_INITIAL_SKILLS=1
|
|
FORCE_BUILD=0
|
|
REPLACE=0
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
./create-instance.sh --instance-id demo --auth-username admin --auth-password 123456 [options]
|
|
|
|
Required:
|
|
--instance-id <id> Unique instance id.
|
|
--auth-username <name> Initial web login username.
|
|
--auth-password <password> Initial web login password.
|
|
|
|
Optional:
|
|
--image <name> Docker image tag. Default: beaver/app-instance:latest
|
|
--container-name <name> Docker container name. Default: app-instance-<slug>
|
|
--host-port <port> Host port to publish. Default: auto-pick from 20000-29999.
|
|
--public-url <url> Public URL exposed to users. Default: http://127.0.0.1:<host-port>
|
|
--provider <name> Provider key in config.json. Default: openai
|
|
--api-base <url> Optional custom provider base URL.
|
|
--api-key <key> Provider API key for Boardware Genius.
|
|
--model <name> Model name. Default: openai/gpt-5
|
|
--skip-provider-config Create the instance without model/provider/API key settings.
|
|
--authz-base-url <url> AuthZ service base URL.
|
|
--authz-internal-token <token>
|
|
AuthZ internal token for backend-only user file storage settings lookup.
|
|
--authz-outlook-mcp-url <url>
|
|
Managed Outlook MCP URL for AuthZ mode.
|
|
--outlook-mcp-server-id <id>
|
|
Default Outlook MCP server id. Default: outlook_mcp
|
|
--user-files-max-upload-bytes <bytes>
|
|
Optional max upload size for the user file system.
|
|
--external-connector-base-url <url>
|
|
External connector sidecar URL. Default: http://external-connector:8787
|
|
--external-connector-token <token>
|
|
Service token used for Beaver-to-sidecar requests.
|
|
--external-connector-callback-base-url <url>
|
|
Internal URL the sidecar should call back for inbound events.
|
|
Default: http://<container-name>:8080 when a Docker network is used.
|
|
--bridge-token <token> Service token accepted from the connector bridge.
|
|
--backend-id <id> Pre-assigned backend id.
|
|
--client-id <id> Pre-assigned AuthZ client id.
|
|
--client-secret <secret> Pre-assigned AuthZ client secret.
|
|
--backend-name <name> Display name in backend identity.
|
|
--auth-portal-url <url> Shared auth portal URL used when building the image.
|
|
--auth-portal-port <port> Fallback auth portal port. Default: 3081
|
|
--username <name> Registry username owner. Default: auth username
|
|
--email <value> Registry email owner.
|
|
--instance-host <host> Public host used by reverse proxy, for registry only.
|
|
--instances-root <path> Instance data root. Default: ./runtime/instances
|
|
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
|
|
--network <name> Optional docker network name.
|
|
--host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1
|
|
--initial-skills-dir <path> Directory copied into workspace/skills on first create.
|
|
Default: ../skills
|
|
--skip-initial-skills Do not seed initial workspace skills.
|
|
--build Force rebuild image before running.
|
|
--replace Remove existing container with same name before running.
|
|
--help Show this help.
|
|
EOF
|
|
}
|
|
|
|
log() {
|
|
printf '[create-instance] %s\n' "$*"
|
|
}
|
|
|
|
die() {
|
|
printf '[create-instance] %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
slugify() {
|
|
local input="$1"
|
|
local output
|
|
output="$(printf '%s' "$input" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//')"
|
|
if [[ -z "$output" ]]; then
|
|
die "instance id produced an empty slug"
|
|
fi
|
|
printf '%s' "$output"
|
|
}
|
|
|
|
pick_free_port() {
|
|
local args=(
|
|
--registry "$REGISTRY_PATH"
|
|
next-port
|
|
--start 20000
|
|
--end 29999
|
|
)
|
|
if [[ -n "$INSTANCE_ID" ]]; then
|
|
args+=(--exclude-instance-id "$INSTANCE_ID")
|
|
fi
|
|
"$REGISTRY_TOOL" "${args[@]}"
|
|
}
|
|
|
|
extract_url_host() {
|
|
local input_url="$1"
|
|
INPUT_URL="$input_url" python3 - <<'PY'
|
|
import os
|
|
from urllib.parse import urlsplit
|
|
|
|
value = os.environ["INPUT_URL"].strip()
|
|
parts = urlsplit(value)
|
|
print(parts.hostname or "")
|
|
PY
|
|
}
|
|
|
|
render_config_json() {
|
|
local target_path="$1"
|
|
|
|
TARGET_PATH="$target_path" \
|
|
MODEL="$MODEL" \
|
|
PROVIDER="$PROVIDER" \
|
|
API_KEY="$API_KEY" \
|
|
API_BASE="$API_BASE" \
|
|
SKIP_PROVIDER_CONFIG="$SKIP_PROVIDER_CONFIG" \
|
|
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
|
AUTHZ_INTERNAL_TOKEN="$AUTHZ_INTERNAL_TOKEN" \
|
|
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
|
OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \
|
|
BACKEND_ID="$BACKEND_ID" \
|
|
CLIENT_ID="$CLIENT_ID" \
|
|
CLIENT_SECRET="$CLIENT_SECRET" \
|
|
BACKEND_NAME="$BACKEND_NAME" \
|
|
PUBLIC_URL="$PUBLIC_URL" \
|
|
python3 - <<'PY'
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
target = Path(os.environ["TARGET_PATH"])
|
|
provider = os.environ["PROVIDER"]
|
|
outlook_mcp_url = os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip()
|
|
outlook_server_id = os.environ["OUTLOOK_MCP_SERVER_ID"].strip() or "outlook_mcp"
|
|
skip_provider_config = os.environ["SKIP_PROVIDER_CONFIG"].strip() == "1"
|
|
|
|
providers = {}
|
|
agent_defaults = {
|
|
"workspace": "/root/.beaver/workspace",
|
|
}
|
|
if not skip_provider_config:
|
|
provider_cfg = {"apiKey": os.environ["API_KEY"]}
|
|
api_base = os.environ["API_BASE"].strip()
|
|
if api_base:
|
|
provider_cfg["apiBase"] = api_base
|
|
providers[provider] = provider_cfg
|
|
agent_defaults["provider"] = provider
|
|
agent_defaults["model"] = os.environ["MODEL"]
|
|
|
|
outlook_tool_names = [
|
|
"auth_status",
|
|
"mail_list_folders",
|
|
"mail_list_messages",
|
|
"mail_search_messages",
|
|
"mail_get_message",
|
|
"mail_send_email",
|
|
"mail_reply_to_message",
|
|
"mail_forward_message",
|
|
"mail_move_message",
|
|
"mail_delta_sync",
|
|
"calendar_list_events",
|
|
"calendar_create_event",
|
|
"calendar_update_event",
|
|
"calendar_get_schedule",
|
|
"calendar_find_meeting_times",
|
|
"calendar_delta_sync",
|
|
]
|
|
|
|
default_mcp_servers = {}
|
|
if outlook_mcp_url:
|
|
default_mcp_servers[outlook_server_id] = {
|
|
"command": "",
|
|
"args": [],
|
|
"env": {},
|
|
"url": outlook_mcp_url,
|
|
"headers": {},
|
|
"authMode": "oauth_backend_token",
|
|
"authAudience": f"mcp:{outlook_server_id}",
|
|
"authScopes": ["list_tools", *[f"tool:{name}" for name in outlook_tool_names]],
|
|
"toolTimeout": 60,
|
|
"sensitive": True,
|
|
}
|
|
|
|
data = {
|
|
"agents": {
|
|
"defaults": agent_defaults
|
|
},
|
|
"providers": providers,
|
|
"tools": {
|
|
"restrictToWorkspace": True,
|
|
"mcpServers": default_mcp_servers,
|
|
},
|
|
"authz": {
|
|
"enabled": bool(os.environ["AUTHZ_BASE_URL"].strip()),
|
|
"baseUrl": os.environ["AUTHZ_BASE_URL"].strip() or "http://127.0.0.1:19090",
|
|
"requestTimeoutSeconds": 10,
|
|
"outlookMcpUrl": outlook_mcp_url,
|
|
},
|
|
"backend_identity": {
|
|
"backendId": os.environ["BACKEND_ID"].strip(),
|
|
"clientId": os.environ["CLIENT_ID"].strip(),
|
|
"clientSecret": os.environ["CLIENT_SECRET"].strip(),
|
|
"name": os.environ["BACKEND_NAME"].strip(),
|
|
"publicBaseUrl": os.environ["PUBLIC_URL"].strip(),
|
|
},
|
|
"channels": {
|
|
"telegram-main": {
|
|
"enabled": False,
|
|
"kind": "telegram",
|
|
"mode": "polling",
|
|
"accountId": "bot-main",
|
|
"displayName": "Telegram Main",
|
|
"secrets": {
|
|
"botToken": "",
|
|
},
|
|
"config": {
|
|
"requireMentionInGroups": True,
|
|
"maxMessageChars": 4096,
|
|
},
|
|
},
|
|
"feishu-main": {
|
|
"enabled": False,
|
|
"kind": "feishu",
|
|
"mode": "websocket",
|
|
"accountId": "tenant-main",
|
|
"displayName": "Feishu Main",
|
|
"secrets": {
|
|
"appId": "",
|
|
"appSecret": "",
|
|
},
|
|
"config": {
|
|
"domain": "feishu",
|
|
"connectionMode": "websocket",
|
|
"requireMentionInGroups": True,
|
|
},
|
|
},
|
|
"qqbot-main": {
|
|
"enabled": False,
|
|
"kind": "qqbot",
|
|
"mode": "websocket",
|
|
"accountId": "qqbot-main",
|
|
"displayName": "QQ Bot Main",
|
|
"secrets": {
|
|
"appId": "",
|
|
"clientSecret": "",
|
|
},
|
|
"config": {
|
|
"dmPolicy": "open",
|
|
"groupPolicy": "allowlist",
|
|
"markdownSupport": False,
|
|
},
|
|
},
|
|
"weixin-main": {
|
|
"enabled": False,
|
|
"kind": "weixin",
|
|
"mode": "polling",
|
|
"accountId": "wx-main",
|
|
"displayName": "Weixin Main",
|
|
"secrets": {
|
|
"token": "",
|
|
},
|
|
"config": {
|
|
"dmPolicy": "open",
|
|
"groupPolicy": "disabled",
|
|
"textBatchDelaySeconds": 0.5,
|
|
},
|
|
},
|
|
"terminal-dev": {
|
|
"enabled": True,
|
|
"kind": "terminal",
|
|
"mode": "websocket",
|
|
"accountId": "local",
|
|
"displayName": "Terminal Dev",
|
|
"config": {},
|
|
"secrets": {},
|
|
},
|
|
},
|
|
}
|
|
|
|
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
PY
|
|
}
|
|
|
|
render_auth_users_json() {
|
|
local target_path="$1"
|
|
|
|
TARGET_PATH="$target_path" \
|
|
AUTH_USERNAME="$AUTH_USERNAME" \
|
|
AUTH_PASSWORD="$AUTH_PASSWORD" \
|
|
python3 - <<'PY'
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
target = Path(os.environ["TARGET_PATH"])
|
|
data = {
|
|
"users": [
|
|
{
|
|
"username": os.environ["AUTH_USERNAME"],
|
|
"password": os.environ["AUTH_PASSWORD"],
|
|
}
|
|
]
|
|
}
|
|
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
PY
|
|
}
|
|
|
|
seed_initial_skills() {
|
|
local workspace_path="$1"
|
|
local initial_skills_dir="$2"
|
|
local target_dir="${workspace_path}/skills"
|
|
|
|
if [[ "$SEED_INITIAL_SKILLS" -ne 1 ]]; then
|
|
return
|
|
fi
|
|
if [[ ! -d "$initial_skills_dir" ]]; then
|
|
log "initial skills directory not found, skipping: ${initial_skills_dir}"
|
|
return
|
|
fi
|
|
|
|
mkdir -p "$target_dir"
|
|
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" INITIAL_SKILLS_EXCLUDE="$INITIAL_SKILLS_EXCLUDE" python3 - <<'PY'
|
|
import json
|
|
import shutil
|
|
import os
|
|
from pathlib import Path
|
|
|
|
initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve()
|
|
target = Path(os.environ["TARGET_DIR"]).resolve()
|
|
excluded = {item.strip() for item in os.environ.get("INITIAL_SKILLS_EXCLUDE", "").split(",") if item.strip()}
|
|
|
|
for child in sorted(initial.iterdir()):
|
|
if child.name.startswith("."):
|
|
continue
|
|
if child.name in excluded:
|
|
continue
|
|
destination = target / child.name
|
|
if destination.exists():
|
|
continue
|
|
if child.is_dir():
|
|
shutil.copytree(child, destination)
|
|
elif child.is_file():
|
|
shutil.copy2(child, destination)
|
|
|
|
for index_name in ("published", "disabled"):
|
|
initial_index = initial / "_index" / f"{index_name}.json"
|
|
target_index = target / "_index" / f"{index_name}.json"
|
|
if not initial_index.exists():
|
|
continue
|
|
try:
|
|
initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", [])
|
|
except json.JSONDecodeError:
|
|
initial_items = []
|
|
if target_index.exists():
|
|
try:
|
|
target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", [])
|
|
except json.JSONDecodeError:
|
|
target_items = []
|
|
else:
|
|
target_items = []
|
|
merged = []
|
|
for item in [*target_items, *initial_items]:
|
|
text = str(item).strip()
|
|
if text in excluded:
|
|
continue
|
|
if text and text not in merged:
|
|
merged.append(text)
|
|
target_index.parent.mkdir(parents=True, exist_ok=True)
|
|
target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
PY
|
|
}
|
|
|
|
render_runtime_env_file() {
|
|
local target_path="$1"
|
|
|
|
TARGET_PATH="$target_path" \
|
|
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
|
AUTHZ_INTERNAL_TOKEN="$AUTHZ_INTERNAL_TOKEN" \
|
|
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
|
BACKEND_ID="$BACKEND_ID" \
|
|
CLIENT_ID="$CLIENT_ID" \
|
|
CLIENT_SECRET="$CLIENT_SECRET" \
|
|
BACKEND_NAME="$BACKEND_NAME" \
|
|
PUBLIC_URL="$PUBLIC_URL" \
|
|
python3 - <<'PY'
|
|
import os
|
|
import shlex
|
|
from pathlib import Path
|
|
|
|
target = Path(os.environ["TARGET_PATH"])
|
|
values = {
|
|
"BEAVER_AUTHZ__ENABLED": "1" if os.environ["AUTHZ_BASE_URL"].strip() else "0",
|
|
"BEAVER_AUTHZ__BASE_URL": os.environ["AUTHZ_BASE_URL"].strip(),
|
|
"BEAVER_AUTHZ_INTERNAL_TOKEN": os.environ["AUTHZ_INTERNAL_TOKEN"].strip(),
|
|
"BEAVER_AUTHZ__OUTLOOK_MCP_URL": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(),
|
|
"BEAVER_BACKEND_IDENTITY__BACKEND_ID": os.environ["BACKEND_ID"].strip(),
|
|
"BEAVER_BACKEND_IDENTITY__CLIENT_ID": os.environ["CLIENT_ID"].strip(),
|
|
"BEAVER_BACKEND_IDENTITY__CLIENT_SECRET": os.environ["CLIENT_SECRET"].strip(),
|
|
"BEAVER_BACKEND_IDENTITY__NAME": os.environ["BACKEND_NAME"].strip(),
|
|
"BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL": os.environ["PUBLIC_URL"].strip(),
|
|
}
|
|
ordered_keys = [
|
|
"BEAVER_AUTHZ__ENABLED",
|
|
"BEAVER_AUTHZ__BASE_URL",
|
|
"BEAVER_AUTHZ_INTERNAL_TOKEN",
|
|
"BEAVER_AUTHZ__OUTLOOK_MCP_URL",
|
|
"BEAVER_BACKEND_IDENTITY__BACKEND_ID",
|
|
"BEAVER_BACKEND_IDENTITY__CLIENT_ID",
|
|
"BEAVER_BACKEND_IDENTITY__CLIENT_SECRET",
|
|
"BEAVER_BACKEND_IDENTITY__NAME",
|
|
"BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL",
|
|
]
|
|
lines: list[str] = []
|
|
for key in ordered_keys:
|
|
value = values.get(key, "")
|
|
if value:
|
|
lines.append(f"export {key}={shlex.quote(value)}")
|
|
continue
|
|
if key == "BEAVER_AUTHZ__ENABLED":
|
|
lines.append("export BEAVER_AUTHZ__ENABLED=0")
|
|
else:
|
|
lines.append(f"unset {key}")
|
|
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
PY
|
|
}
|
|
|
|
image_exists() {
|
|
docker image inspect "$IMAGE_NAME" >/dev/null 2>&1
|
|
}
|
|
|
|
container_exists() {
|
|
docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--instance-id)
|
|
INSTANCE_ID="${2:-}"
|
|
shift 2
|
|
;;
|
|
--image)
|
|
IMAGE_NAME="${2:-}"
|
|
shift 2
|
|
;;
|
|
--container-name)
|
|
CONTAINER_NAME="${2:-}"
|
|
shift 2
|
|
;;
|
|
--host-port)
|
|
HOST_PORT="${2:-}"
|
|
shift 2
|
|
;;
|
|
--public-url)
|
|
PUBLIC_URL="${2:-}"
|
|
shift 2
|
|
;;
|
|
--provider)
|
|
PROVIDER="${2:-}"
|
|
shift 2
|
|
;;
|
|
--api-key)
|
|
API_KEY="${2:-}"
|
|
shift 2
|
|
;;
|
|
--api-base)
|
|
API_BASE="${2:-}"
|
|
shift 2
|
|
;;
|
|
--model)
|
|
MODEL="${2:-}"
|
|
shift 2
|
|
;;
|
|
--skip-provider-config)
|
|
SKIP_PROVIDER_CONFIG=1
|
|
shift
|
|
;;
|
|
--auth-username)
|
|
AUTH_USERNAME="${2:-}"
|
|
shift 2
|
|
;;
|
|
--auth-password)
|
|
AUTH_PASSWORD="${2:-}"
|
|
shift 2
|
|
;;
|
|
--username)
|
|
USERNAME="${2:-}"
|
|
shift 2
|
|
;;
|
|
--email)
|
|
EMAIL="${2:-}"
|
|
shift 2
|
|
;;
|
|
--instance-host)
|
|
INSTANCE_HOST="${2:-}"
|
|
shift 2
|
|
;;
|
|
--authz-base-url)
|
|
AUTHZ_BASE_URL="${2:-}"
|
|
shift 2
|
|
;;
|
|
--authz-internal-token)
|
|
AUTHZ_INTERNAL_TOKEN="${2:-}"
|
|
shift 2
|
|
;;
|
|
--authz-outlook-mcp-url)
|
|
AUTHZ_OUTLOOK_MCP_URL="${2:-}"
|
|
shift 2
|
|
;;
|
|
--outlook-mcp-server-id)
|
|
OUTLOOK_MCP_SERVER_ID="${2:-}"
|
|
shift 2
|
|
;;
|
|
--user-files-max-upload-bytes)
|
|
USER_FILES_MAX_UPLOAD_BYTES="${2:-}"
|
|
shift 2
|
|
;;
|
|
--external-connector-base-url)
|
|
EXTERNAL_CONNECTOR_BASE_URL="${2:-}"
|
|
shift 2
|
|
;;
|
|
--external-connector-token)
|
|
EXTERNAL_CONNECTOR_TOKEN="${2:-}"
|
|
shift 2
|
|
;;
|
|
--external-connector-callback-base-url)
|
|
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="${2:-}"
|
|
shift 2
|
|
;;
|
|
--bridge-token)
|
|
BEAVER_BRIDGE_TOKEN="${2:-}"
|
|
shift 2
|
|
;;
|
|
--backend-id)
|
|
BACKEND_ID="${2:-}"
|
|
shift 2
|
|
;;
|
|
--client-id)
|
|
CLIENT_ID="${2:-}"
|
|
shift 2
|
|
;;
|
|
--client-secret)
|
|
CLIENT_SECRET="${2:-}"
|
|
shift 2
|
|
;;
|
|
--backend-name)
|
|
BACKEND_NAME="${2:-}"
|
|
shift 2
|
|
;;
|
|
--auth-portal-url)
|
|
AUTH_PORTAL_URL="${2:-}"
|
|
shift 2
|
|
;;
|
|
--auth-portal-port)
|
|
AUTH_PORTAL_PORT="${2:-}"
|
|
shift 2
|
|
;;
|
|
--instances-root)
|
|
INSTANCES_ROOT="${2:-}"
|
|
shift 2
|
|
;;
|
|
--registry)
|
|
REGISTRY_PATH="${2:-}"
|
|
shift 2
|
|
;;
|
|
--network)
|
|
NETWORK_NAME="${2:-}"
|
|
shift 2
|
|
;;
|
|
--host-bind-ip)
|
|
HOST_BIND_IP="${2:-}"
|
|
shift 2
|
|
;;
|
|
--initial-skills-dir)
|
|
INITIAL_SKILLS_DIR="${2:-}"
|
|
shift 2
|
|
;;
|
|
--skip-initial-skills)
|
|
SEED_INITIAL_SKILLS=0
|
|
shift
|
|
;;
|
|
--build)
|
|
FORCE_BUILD=1
|
|
shift
|
|
;;
|
|
--replace)
|
|
REPLACE=1
|
|
shift
|
|
;;
|
|
--help|-h)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
die "unknown argument: $1"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
[[ -n "$INSTANCE_ID" ]] || die "--instance-id is required"
|
|
[[ -n "$AUTH_USERNAME" ]] || die "--auth-username is required"
|
|
[[ -n "$AUTH_PASSWORD" ]] || die "--auth-password is required"
|
|
if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then
|
|
[[ -n "$API_KEY" ]] || die "--api-key is required unless --skip-provider-config is set"
|
|
fi
|
|
|
|
INSTANCE_SLUG="$(slugify "$INSTANCE_ID")"
|
|
USERNAME="${USERNAME:-$AUTH_USERNAME}"
|
|
EXISTING_RECORD_JSON=""
|
|
EXISTING_CONTAINER_NAME=""
|
|
EXISTING_HOST_PORT=""
|
|
|
|
if EXISTING_RECORD_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" get --instance-id "$INSTANCE_ID" 2>/dev/null)"; then
|
|
EXISTING_CONTAINER_NAME="$(printf '%s' "$EXISTING_RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["container_name"])')"
|
|
EXISTING_HOST_PORT="$(printf '%s' "$EXISTING_RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["host_port"])')"
|
|
if [[ "$REPLACE" -ne 1 ]]; then
|
|
die "instance already exists in registry: ${INSTANCE_ID} (use --replace to recreate)"
|
|
fi
|
|
fi
|
|
|
|
CONTAINER_NAME="${CONTAINER_NAME:-${EXISTING_CONTAINER_NAME:-app-instance-${INSTANCE_SLUG}}}"
|
|
BACKEND_NAME="${BACKEND_NAME:-${INSTANCE_ID}}"
|
|
|
|
if [[ -z "$HOST_PORT" ]]; then
|
|
HOST_PORT="${EXISTING_HOST_PORT:-$(pick_free_port)}"
|
|
fi
|
|
|
|
if [[ -z "$PUBLIC_URL" ]]; then
|
|
PUBLIC_URL="http://127.0.0.1:${HOST_PORT}"
|
|
fi
|
|
|
|
if [[ -z "$INSTANCE_HOST" ]]; then
|
|
INSTANCE_HOST="$(extract_url_host "$PUBLIC_URL")"
|
|
fi
|
|
|
|
if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then
|
|
case "$KNOWN_PROVIDERS" in
|
|
*" ${PROVIDER} "*) ;;
|
|
*) die "unsupported provider '${PROVIDER}'" ;;
|
|
esac
|
|
fi
|
|
|
|
if [[ -n "$INITIAL_SKILLS_DIR" ]]; then
|
|
INITIAL_SKILLS_DIR="$(INITIAL_SKILLS_DIR="$INITIAL_SKILLS_DIR" python3 - <<'PY'
|
|
import os
|
|
from pathlib import Path
|
|
|
|
print(Path(os.environ["INITIAL_SKILLS_DIR"]).expanduser().resolve())
|
|
PY
|
|
)"
|
|
fi
|
|
|
|
if [[ -n "$BACKEND_ID$CLIENT_ID$CLIENT_SECRET" ]]; then
|
|
[[ -n "$BACKEND_ID" && -n "$CLIENT_ID" && -n "$CLIENT_SECRET" ]] || die "backend identity requires --backend-id, --client-id and --client-secret together"
|
|
fi
|
|
|
|
if PORT_HOLDER_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" get --container-name "$CONTAINER_NAME" 2>/dev/null)"; then
|
|
REGISTERED_ID="$(printf '%s' "$PORT_HOLDER_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["instance_id"])')"
|
|
if [[ "$REGISTERED_ID" != "$INSTANCE_ID" && "$REPLACE" -ne 1 ]]; then
|
|
die "container name already registered for another instance: ${CONTAINER_NAME}"
|
|
fi
|
|
fi
|
|
|
|
if PORT_HOLDER_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" list --json)"; then
|
|
PORT_CONFLICT_ID="$(PORT_HOLDER_JSON="$PORT_HOLDER_JSON" python3 - "$HOST_PORT" "$INSTANCE_ID" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
port = int(sys.argv[1])
|
|
instance_id = sys.argv[2]
|
|
for item in json.loads(os.environ["PORT_HOLDER_JSON"]).get("instances", []):
|
|
if int(item.get("host_port", 0) or 0) == port and item.get("instance_id") != instance_id:
|
|
print(item.get("instance_id", ""))
|
|
break
|
|
PY
|
|
)"
|
|
if [[ -n "$PORT_CONFLICT_ID" ]]; then
|
|
die "host port already registered by another instance: ${HOST_PORT} (${PORT_CONFLICT_ID})"
|
|
fi
|
|
fi
|
|
|
|
INSTANCE_ROOT="${INSTANCES_ROOT}/${INSTANCE_SLUG}"
|
|
BEAVER_HOME="${INSTANCE_ROOT}/beaver-home"
|
|
CONFIG_PATH="${BEAVER_HOME}/config.json"
|
|
AUTH_USERS_PATH="${BEAVER_HOME}/web_auth_users.json"
|
|
RUNTIME_ENV_PATH="${BEAVER_HOME}/runtime.env"
|
|
WORKSPACE_PATH="${BEAVER_HOME}/workspace"
|
|
|
|
mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH"
|
|
|
|
render_config_json "$CONFIG_PATH"
|
|
render_auth_users_json "$AUTH_USERS_PATH"
|
|
render_runtime_env_file "$RUNTIME_ENV_PATH"
|
|
seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR"
|
|
|
|
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
|
|
log "building image ${IMAGE_NAME}"
|
|
docker build \
|
|
--build-arg "NEXT_PUBLIC_AUTH_PORTAL_URL=${AUTH_PORTAL_URL}" \
|
|
--build-arg "NEXT_PUBLIC_AUTH_PORTAL_PORT=${AUTH_PORTAL_PORT}" \
|
|
-t "$IMAGE_NAME" \
|
|
"$SCRIPT_DIR"
|
|
fi
|
|
|
|
if container_exists; then
|
|
if [[ "$REPLACE" -eq 1 ]]; then
|
|
log "removing existing container ${CONTAINER_NAME}"
|
|
docker rm -f "$CONTAINER_NAME" >/dev/null
|
|
else
|
|
die "container already exists: ${CONTAINER_NAME} (use --replace to recreate)"
|
|
fi
|
|
fi
|
|
|
|
RUN_ARGS=(
|
|
-d
|
|
--name "$CONTAINER_NAME"
|
|
--restart unless-stopped
|
|
-p "${HOST_BIND_IP}:${HOST_PORT}:8080"
|
|
-v "${BEAVER_HOME}:/root/.beaver"
|
|
-e "BEAVER_HOME=/root/.beaver"
|
|
-e "BEAVER_CONFIG_PATH=/root/.beaver/config.json"
|
|
-e "BEAVER_WORKSPACE=/root/.beaver/workspace"
|
|
-e "BEAVER_AUTH_FILE=/root/.beaver/web_auth_users.json"
|
|
-e "BEAVER_FRONTEND_PUBLIC_BASE_URL=${PUBLIC_URL}"
|
|
-e "APP_PUBLIC_PORT=8080"
|
|
-e "APP_FRONTEND_PORT=3000"
|
|
-e "APP_BACKEND_PORT=18080"
|
|
-e "BEAVER_ENABLE_SELF_RESTART=1"
|
|
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
|
-e "EXTERNAL_CONNECTOR_BASE_URL=${EXTERNAL_CONNECTOR_BASE_URL}"
|
|
--label "beaver.instance.id=${INSTANCE_ID}"
|
|
--label "beaver.instance.slug=${INSTANCE_SLUG}"
|
|
--label "beaver.instance.public_url=${PUBLIC_URL}"
|
|
)
|
|
|
|
if [[ -z "$EXTERNAL_CONNECTOR_CALLBACK_BASE_URL" && -n "$NETWORK_NAME" ]]; then
|
|
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="http://${CONTAINER_NAME}:8080"
|
|
fi
|
|
|
|
if [[ "$SEED_INITIAL_SKILLS" -eq 1 && -n "$INITIAL_SKILLS_DIR" ]]; then
|
|
RUN_ARGS+=(
|
|
-v "${INITIAL_SKILLS_DIR}:/opt/app/initial-skills:ro"
|
|
-e "BEAVER_INITIAL_SKILLS_DIR=/opt/app/initial-skills"
|
|
-e "BEAVER_INITIAL_SKILLS_EXCLUDE=${INITIAL_SKILLS_EXCLUDE}"
|
|
)
|
|
fi
|
|
|
|
if [[ -n "$USER_FILES_MAX_UPLOAD_BYTES" ]]; then
|
|
RUN_ARGS+=(-e "BEAVER_USER_FILES_MAX_UPLOAD_BYTES=${USER_FILES_MAX_UPLOAD_BYTES}")
|
|
fi
|
|
if [[ -n "$EXTERNAL_CONNECTOR_TOKEN" ]]; then
|
|
RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_TOKEN=${EXTERNAL_CONNECTOR_TOKEN}")
|
|
fi
|
|
if [[ -n "$EXTERNAL_CONNECTOR_CALLBACK_BASE_URL" ]]; then
|
|
RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=${EXTERNAL_CONNECTOR_CALLBACK_BASE_URL}")
|
|
fi
|
|
if [[ -n "$BEAVER_BRIDGE_TOKEN" ]]; then
|
|
RUN_ARGS+=(-e "BEAVER_BRIDGE_TOKEN=${BEAVER_BRIDGE_TOKEN}")
|
|
fi
|
|
|
|
if [[ -n "$NETWORK_NAME" ]]; then
|
|
RUN_ARGS+=(--network "$NETWORK_NAME")
|
|
fi
|
|
|
|
log "starting container ${CONTAINER_NAME}"
|
|
docker run "${RUN_ARGS[@]}" "$IMAGE_NAME" >/dev/null
|
|
|
|
"$REGISTRY_TOOL" --registry "$REGISTRY_PATH" upsert \
|
|
--instance-id "$INSTANCE_ID" \
|
|
--instance-slug "$INSTANCE_SLUG" \
|
|
--container-name "$CONTAINER_NAME" \
|
|
--image-name "$IMAGE_NAME" \
|
|
--host-port "$HOST_PORT" \
|
|
--public-url "$PUBLIC_URL" \
|
|
--instance-root "$INSTANCE_ROOT" \
|
|
--beaver-home "$BEAVER_HOME" \
|
|
--config-path "$CONFIG_PATH" \
|
|
--auth-users-path "$AUTH_USERS_PATH" \
|
|
--network-name "$NETWORK_NAME" \
|
|
--backend-id "$BACKEND_ID" \
|
|
--backend-name "$BACKEND_NAME" \
|
|
--authz-base-url "$AUTHZ_BASE_URL" \
|
|
--username "$USERNAME" \
|
|
--email "$EMAIL" \
|
|
--instance-host "$INSTANCE_HOST" \
|
|
--frontend-base-url "$PUBLIC_URL" \
|
|
--api-base-url "$PUBLIC_URL" \
|
|
--created-at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >/dev/null
|
|
|
|
cat <<EOF
|
|
instance_id=${INSTANCE_ID}
|
|
instance_slug=${INSTANCE_SLUG}
|
|
container_name=${CONTAINER_NAME}
|
|
image_name=${IMAGE_NAME}
|
|
host_port=${HOST_PORT}
|
|
public_url=${PUBLIC_URL}
|
|
instance_root=${INSTANCE_ROOT}
|
|
beaver_home=${BEAVER_HOME}
|
|
config_path=${CONFIG_PATH}
|
|
auth_users_path=${AUTH_USERS_PATH}
|
|
runtime_env_path=${RUNTIME_ENV_PATH}
|
|
username=${USERNAME}
|
|
email=${EMAIL}
|
|
instance_host=${INSTANCE_HOST}
|
|
host_bind_ip=${HOST_BIND_IP}
|
|
EOF
|