Skip to content
Closed
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
96 changes: 96 additions & 0 deletions .github/workflows/self-hosted-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Self-hosted CI (tempsdev1)

# SAFETY: this workflow runs on the self-hosted EC2 runner, so it MUST NOT be
# reachable by untrusted code. It triggers ONLY on pushes to branches in this
# repo (strangers cannot push to your fork) and on manual dispatch. There is
# deliberately no `pull_request` trigger here — fork PR CI runs on isolated
# GitHub-hosted runners via rust-tests.yml instead. Never add `pull_request`
# or `pull_request_target` to a self-hosted job.
on:
push:
branches:
- main
- 'feature/**'
- 'fix/**'
workflow_dispatch:

permissions:
contents: read

# One run per branch; newer pushes cancel older queued runs so the single
# runner doesn't pile up.
concurrency:
group: self-hosted-ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-test:
runs-on: [self-hosted, linux, x64]
steps:
# Persistent local build cache. CARGO_TARGET_DIR is pinned OUTSIDE the
# runner's _work dir so actions/checkout's `git clean -ffdx` never wipes
# it. CARGO_HOME is left at its default (~/.cargo) so the runner reuses
# the box's already-warm registry instead of re-downloading crates.
# This is plain local disk, not subject to the 10 GB Actions cache limit.
- name: Configure persistent cargo target dir
run: echo "CARGO_TARGET_DIR=$HOME/.cache/temps-ci/target" >> "$GITHUB_ENV"

- uses: actions/checkout@v4

# No toolchain step: the box uses the system Rust (Amazon Linux package,
# /usr/bin/cargo, currently 1.95.0 — already >= the 1.91 the AWS crates
# need). There is no rustup on this host, so dtolnay/rust-toolchain would
# not work here.
- name: Toolchain info
run: cargo --version && rustc --version

# Several crates embed a built web UI via `include_dir!(".../dist")`
# (temps-cli and the example plugins). Their build.rs writes that dir into
# the (gitignored) source tree, but only when it actually runs — and with
# a persistent CARGO_TARGET_DIR the build-script fingerprint is cached, so
# after actions/checkout's `git clean -ffdx` wipes the dirs the scripts are
# skipped and the macro panics on a missing dir. Recreate the same debug
# placeholders up front so `cargo check --all-targets` is independent of
# build-script cache state. Keep this list in sync with `include_dir!`
# call sites: `git grep -n 'include_dir!' -- '*.rs'`.
- name: Prepare embedded web assets
run: |
dist_dirs="crates/temps-cli/dist"
for web in examples/*/web; do dist_dirs="$dist_dirs $web/dist"; done
for d in $dist_dirs; do
mkdir -p "$d"
[ -f "$d/index.html" ] || \
printf '<!doctype html><title>placeholder</title>\n' > "$d/index.html"
done

- name: cargo check
run: cargo check --workspace --all-targets

# Library unit tests for the same crate set rust-tests.yml runs in its
# `unit-a`/`unit-b` jobs. The integration-heavy crates (temps-domains,
# temps-deployments, temps-deployer, …) are deliberately excluded here —
# hosted runs those in dedicated Docker+networking integration jobs, and
# tests like the real-Pebble ACME HTTP-01 flow need a challenge-responder
# network this box doesn't provide. Keep this list in sync with the
# unit-a/unit-b matrix in rust-tests.yml. (temps-captcha-wasm is omitted:
# it needs a wasm-pack build step we don't run here.)
#
# temps-screenshots is also excluded: its local_provider tests launch a
# real bundled Chromium against live example.com/github.com. Hosted CI
# passes because the ubuntu-latest image ships google-chrome system-wide;
# this box has no system browser, so headless_chrome auto-downloads one
# and the parallel tests race on the binary ("Text file busy") and debug
# ports ("no available ports between 8000 and 9000"). It's a live-network
# browser integration test, not a unit test — hosted already covers it.
- name: cargo test (unit crates)
run: |
cargo test --no-fail-fast --lib \
-p temps-providers -p temps-presets -p temps-auth -p temps-core \
-p temps-monitoring -p temps-kv -p temps-analytics-funnels \
-p temps-webhooks -p temps-infra -p temps-geo -p temps-environments \
-p temps-audit -p temps-analytics-performance -p temps-proxy \
-p temps-error-tracking -p temps-git \
-p temps-routes -p temps-entities -p temps-cli -p temps-status-page \
-p temps-import -p temps-queue -p temps-query \
-p temps-analytics-session-replay -p temps-dns -p temps-email \
-p temps-analytics -p temps-analytics-events -p temps-otel
2 changes: 2 additions & 0 deletions CHANGELOG.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion crates/temps-ai-gateway/src/providers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ impl Default for AnthropicProvider {

impl AnthropicProvider {
pub fn new() -> Self {
let client = external_http_client(Duration::from_secs(300));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Failed to build HTTP client");

Self {
info: ProviderInfo {
Expand Down
7 changes: 6 additions & 1 deletion crates/temps-ai-gateway/src/providers/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ impl Default for GeminiProvider {

impl GeminiProvider {
pub fn new() -> Self {
let client = external_http_client(Duration::from_secs(300));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Failed to build HTTP client");

Self {
info: ProviderInfo {
Expand Down
7 changes: 6 additions & 1 deletion crates/temps-ai-gateway/src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ pub struct OpenAiCompatProvider {

impl OpenAiCompatProvider {
pub fn new(info: ProviderInfo, models: Vec<ModelInfo>) -> Self {
let client = external_http_client(Duration::from_secs(300));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Failed to build HTTP client");

Self {
info,
Expand Down
19 changes: 14 additions & 5 deletions crates/temps-ai-gateway/src/services/gateway_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ use crate::providers::{external_http_client, route_model_to_provider, AiProvider
use crate::services::provider_key_service::ProviderKeyService;
use crate::types::*;

fn validate_provider_base_url(base_url: &str) -> Result<(), AiGatewayError> {
temps_core::url_validation::validate_external_url(base_url).map_err(|e| {
AiGatewayError::InvalidProviderUrl {
reason: e.to_string(),
}
})?;
Ok(())
}

/// Indicates how the provider API key was resolved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialType {
Expand Down Expand Up @@ -230,11 +239,7 @@ impl GatewayService {
// An authenticated user must not be able to point the gateway at
// internal services (cloud metadata, Docker daemon, private subnets, etc.).
if let Some(ref base_url) = byok.base_url {
temps_core::url_validation::validate_external_url(base_url).map_err(|e| {
AiGatewayError::InvalidProviderUrl {
reason: e.to_string(),
}
})?;
validate_provider_base_url(base_url)?;
}

debug!(
Expand Down Expand Up @@ -300,6 +305,10 @@ impl GatewayService {
"Routing request to provider"
);

if let Some(ref base_url) = key_record.base_url {
validate_provider_base_url(base_url)?;
}

Ok((
provider.as_ref(),
decrypted_key,
Expand Down
21 changes: 15 additions & 6 deletions crates/temps-ai-gateway/src/services/provider_key_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ use temps_entities::ai_provider_models;

use crate::error::AiGatewayError;

fn validate_provider_base_url(base_url: Option<&str>) -> Result<Option<String>, AiGatewayError> {
base_url
.map(|url| {
temps_core::url_validation::validate_external_url(url).map_err(|e| {
AiGatewayError::InvalidProviderUrl {
reason: e.to_string(),
}
})?;
Ok(url.trim_end_matches('/').to_string())
})
.transpose()
}

pub struct ProviderKeyService {
db: Arc<DatabaseConnection>,
encryption_service: Arc<EncryptionService>,
Expand Down Expand Up @@ -53,8 +66,7 @@ impl ProviderKeyService {
provider: Set(provider.to_string()),
display_name: Set(display_name.to_string()),
api_key_encrypted: Set(encrypted_key),
base_url: Set(base_url.map(|s| s.to_string())),
default_model: Set(default_model.map(|s| s.to_string())),
base_url: Set(validate_provider_base_url(base_url)?),
is_active: Set(true),
..Default::default()
};
Expand Down Expand Up @@ -175,10 +187,7 @@ impl ProviderKeyService {
}

if let Some(url) = base_url {
if let Some(url) = url {
validate_provider_url(url).await?;
}
active.base_url = Set(url.map(|s| s.to_string()));
active.base_url = Set(validate_provider_base_url(url)?);
}

if let Some(model) = default_model {
Expand Down
Loading