Compare commits
8 Commits
a53b28e35e
...
46fba794f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 46fba794f3 | |||
| 0fd561726e | |||
| 01fd1ddc81 | |||
| bb7725c323 | |||
| b255042496 | |||
| 7d782daed3 | |||
| 31e77f8f6a | |||
| e58a17bf03 |
@ -47,6 +47,10 @@ WRENN_CA_KEY=
|
||||
# Channels (notification destinations)
|
||||
# AES-256-GCM key for encrypting channel secrets. Generate with: openssl rand -hex 32
|
||||
WRENN_ENCRYPTION_KEY=
|
||||
# Allow notification channels to target private/loopback/link-local addresses.
|
||||
# Off by default to prevent SSRF; set to true only on self-hosted deployments
|
||||
# that deliver to internal endpoints.
|
||||
WRENN_CHANNELS_ALLOW_PRIVATE=false
|
||||
|
||||
# OAuth
|
||||
OAUTH_GITHUB_CLIENT_ID=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -33,7 +33,6 @@ go.work.sum
|
||||
|
||||
## AI
|
||||
.claude/
|
||||
e2b/
|
||||
.impeccable.md
|
||||
.gstack
|
||||
.mcp.json
|
||||
|
||||
@ -60,13 +60,13 @@ User SDK → HTTPS/WS → Control Plane → Connect RPC → Host Agent → HTTP/
|
||||
|
||||
envd is a standalone Rust binary (Tokio + Axum + connectrpc-rs). It is completely independent from the Go module — the only connection is the protobuf contract. It compiles to a statically linked musl binary baked into rootfs images.
|
||||
|
||||
**Key architectural invariant:** The host agent is **stateful** (in-memory `boxes` map is the source of truth for running VMs). The control plane is **stateless** (all persistent state in PostgreSQL). The reconciler (`internal/api/reconciler.go`) bridges the gap — it periodically compares DB records against the host agent's live state and marks orphaned sandboxes as "stopped".
|
||||
**Key architectural invariant:** The host agent is **stateful** (in-memory `boxes` map is the source of truth for running VMs). The control plane is **stateless** (all persistent state in PostgreSQL). The host monitor (`internal/api/host_monitor.go`) bridges the gap — it periodically compares DB records against the host agent's live state and marks orphaned sandboxes as "stopped".
|
||||
|
||||
### Control Plane
|
||||
|
||||
**Internal packages:** `internal/api/`, `internal/email/`
|
||||
|
||||
**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/auth/session/`, `pkg/auth/session/middleware/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/`
|
||||
**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/auth/session/`, `pkg/auth/session/middleware/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/`, `pkg/netutil/`
|
||||
|
||||
**Extension framework:** `pkg/cpextension/` (shared `Extension` interface + `ServerContext` + hook interfaces), `pkg/cpserver/` (exported `Run()` entrypoint with functional options for cloud `main.go`)
|
||||
|
||||
@ -88,7 +88,7 @@ It can optionally implement any of these hook interfaces (the OSS server type-as
|
||||
Startup (`cmd/control-plane/main.go`) is a thin wrapper: `cpserver.Run(cpserver.WithVersion(...))`. All 20 initialization steps live in `pkg/cpserver/run.go`: config → pgxpool → `db.Queries` → Redis → mTLS CA → host client pool → scheduler → OAuth → channels → audit logger → `api.New()` → background workers → HTTP server. Everything flows through constructor injection.
|
||||
|
||||
- **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/capsules/*`. Accepts `[]cpextension.Extension` — each extension's `RegisterRoutes()` is called after all core routes are registered.
|
||||
- **Reconciler** (`internal/api/reconciler.go`): background goroutine (every 30s) that compares DB records against `agent.ListSandboxes()` RPC. Marks orphaned DB entries as "stopped".
|
||||
- **Host monitor** (`internal/api/host_monitor.go`): safety-net reconciliation goroutine (every 5min, wired in `pkg/cpserver/run.go` via `NewHostMonitor`). Primary state sync is push-based (host agent callbacks + CP background goroutines); the monitor is the fallback for missed events, host-death detection, and transient-status resolution. For each online host it calls `ListSandboxes()` and reconciles orphaned DB entries.
|
||||
- **Dashboard** (SvelteKit + Tailwind + Bits UI, built to static files in `frontend/build/`, served by Caddy as a reverse proxy)
|
||||
- **Database**: PostgreSQL via pgx/v5. Queries generated by sqlc from `db/queries/*.sql` → `pkg/db/`. Migrations in `db/migrations/` (goose, plain SQL). `db/migrations/embed.go` exposes `migrations.FS` so the cloud repo can run OSS migrations via `go:embed`.
|
||||
- **Config** (`pkg/config/config.go`): purely environment variables (`DATABASE_URL`, `CP_LISTEN_ADDR`, `CP_HOST_AGENT_ADDR`), no YAML/file config.
|
||||
@ -251,7 +251,7 @@ To add a new query: add it to the appropriate `.sql` file in `db/queries/` → `
|
||||
## Coding Conventions
|
||||
|
||||
- **Go style**: `gofmt`, `go vet`, `context.Context` everywhere, errors wrapped with `fmt.Errorf("action: %w", err)`, `slog` for logging, no global state
|
||||
- **Naming**: Sandbox IDs `sb-` + 8 hex, API keys `wrn_` + 32 chars, Host IDs `host-` + 8 hex
|
||||
- **Naming**: IDs are a type prefix + a 25-char base36-encoded UUID (`pkg/id`) — sandboxes `cl-`, users `usr-`, teams `team-`, hosts `host-`, etc. API keys are `wrn_` + 32 hex
|
||||
- **Dependencies**: Use `go get` to add Go deps, never hand-edit go.mod. For envd-rs deps: edit `envd-rs/Cargo.toml`
|
||||
- **Generated code**: Always commit generated code (proto stubs, sqlc). Never add generated code to .gitignore
|
||||
- **Migrations**: Always use `make migrate-create name=xxx`, never create migration files manually
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Request;
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth::token::SecureToken;
|
||||
|
||||
const ACCESS_TOKEN_HEADER: &str = "x-access-token";
|
||||
|
||||
/// Paths excluded from general token auth.
|
||||
/// Format: "METHOD/path"
|
||||
const AUTH_EXCLUDED: &[&str] = &[
|
||||
"GET/health",
|
||||
"GET/activity",
|
||||
"GET/files",
|
||||
"POST/files",
|
||||
"POST/init",
|
||||
"POST/snapshot/prepare",
|
||||
];
|
||||
|
||||
/// Axum middleware that checks X-Access-Token header.
|
||||
pub async fn auth_layer(request: Request, next: Next, access_token: Arc<SecureToken>) -> Response {
|
||||
if access_token.is_set() {
|
||||
let method = request.method().as_str();
|
||||
let path = request.uri().path();
|
||||
let key = format!("{method}{path}");
|
||||
|
||||
let is_excluded = AUTH_EXCLUDED.iter().any(|p| *p == key);
|
||||
|
||||
let header_val = request
|
||||
.headers()
|
||||
.get(ACCESS_TOKEN_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if !access_token.equals(header_val) && !is_excluded {
|
||||
tracing::error!("unauthorized access attempt");
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
axum::Json(json!({
|
||||
"code": 401,
|
||||
"message": "unauthorized access, please provide a valid access token or method signing if supported"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
@ -1,3 +1,2 @@
|
||||
pub mod middleware;
|
||||
pub mod signing;
|
||||
pub mod token;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::auth::token::SecureToken;
|
||||
use crate::crypto;
|
||||
use zeroize::Zeroize;
|
||||
@ -67,7 +69,9 @@ pub fn validate_signing(
|
||||
let expected = generate_signature(token, path, username, operation, signature_expiration)
|
||||
.map_err(|e| format!("error generating signing key: {e}"))?;
|
||||
|
||||
if expected != sig {
|
||||
// Constant-time compare: both values are fixed-length `v1_<64 hex>` digests,
|
||||
// so this matches the timing-side-channel posture of SecureToken::equals.
|
||||
if expected.as_bytes().ct_eq(sig.as_bytes()).unwrap_u8() != 1 {
|
||||
return Err("invalid signature".into());
|
||||
}
|
||||
|
||||
|
||||
@ -30,8 +30,8 @@ impl FilesystemServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(&self, path: &str, ctx: &Context) -> Result<String, ConnectError> {
|
||||
let username = extract_username(ctx).unwrap_or_else(|| self.state.defaults.user());
|
||||
fn resolve_path(&self, path: &str) -> Result<String, ConnectError> {
|
||||
let username = self.state.defaults.user();
|
||||
let user = lookup_user(&username).map_err(|e| {
|
||||
ConnectError::new(ErrorCode::Unauthenticated, format!("invalid user: {e}"))
|
||||
})?;
|
||||
@ -44,20 +44,13 @@ impl FilesystemServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_username(ctx: &Context) -> Option<String> {
|
||||
ctx.extensions.get::<AuthUser>().map(|u| u.0.clone())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthUser(pub String);
|
||||
|
||||
impl Filesystem for FilesystemServiceImpl {
|
||||
async fn stat(
|
||||
&self,
|
||||
ctx: Context,
|
||||
request: buffa::view::OwnedView<StatRequestView<'static>>,
|
||||
) -> Result<(StatResponse, Context), ConnectError> {
|
||||
let path = self.resolve_path(request.path, &ctx)?;
|
||||
let path = self.resolve_path(request.path)?;
|
||||
let entry = build_entry_info(&path)?;
|
||||
Ok((
|
||||
StatResponse {
|
||||
@ -73,7 +66,7 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
ctx: Context,
|
||||
request: buffa::view::OwnedView<MakeDirRequestView<'static>>,
|
||||
) -> Result<(MakeDirResponse, Context), ConnectError> {
|
||||
let path = self.resolve_path(request.path, &ctx)?;
|
||||
let path = self.resolve_path(request.path)?;
|
||||
|
||||
match std::fs::metadata(&path) {
|
||||
Ok(meta) => {
|
||||
@ -97,7 +90,7 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user());
|
||||
let username = self.state.defaults.user();
|
||||
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
|
||||
ensure_dirs(&path, user.uid, user.gid)
|
||||
@ -118,10 +111,10 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
ctx: Context,
|
||||
request: buffa::view::OwnedView<MoveRequestView<'static>>,
|
||||
) -> Result<(MoveResponse, Context), ConnectError> {
|
||||
let source = self.resolve_path(request.source, &ctx)?;
|
||||
let destination = self.resolve_path(request.destination, &ctx)?;
|
||||
let source = self.resolve_path(request.source)?;
|
||||
let destination = self.resolve_path(request.destination)?;
|
||||
|
||||
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user());
|
||||
let username = self.state.defaults.user();
|
||||
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
|
||||
if let Some(parent) = Path::new(&destination).parent() {
|
||||
@ -157,7 +150,7 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
depth = 1;
|
||||
}
|
||||
|
||||
let path = self.resolve_path(request.path, &ctx)?;
|
||||
let path = self.resolve_path(request.path)?;
|
||||
|
||||
// The recursive walk stats every entry (plus uid/gid lookups) — on a
|
||||
// large tree that is seconds of blocking syscalls, so it runs on the
|
||||
@ -200,7 +193,7 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
ctx: Context,
|
||||
request: buffa::view::OwnedView<RemoveRequestView<'static>>,
|
||||
) -> Result<(RemoveResponse, Context), ConnectError> {
|
||||
let path = self.resolve_path(request.path, &ctx)?;
|
||||
let path = self.resolve_path(request.path)?;
|
||||
|
||||
// remove_dir_all recurses through the whole tree — blocking pool, not
|
||||
// a runtime worker thread.
|
||||
@ -250,7 +243,7 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
) -> Result<(CreateWatcherResponse, Context), ConnectError> {
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
|
||||
let path = self.resolve_path(request.path, &ctx)?;
|
||||
let path = self.resolve_path(request.path)?;
|
||||
let recursive = request.recursive;
|
||||
|
||||
if let Ok(true) = crate::rpc::entry::is_network_mount(&path) {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { apiFailure } from '$lib/api/client';
|
||||
|
||||
export type AuthResponse = {
|
||||
user_id: string;
|
||||
team_id: string;
|
||||
@ -38,8 +40,8 @@ async function authFetch<T = AuthResponse>(url: string, body: Record<string, str
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
const message = data?.error?.message ?? 'Something went wrong';
|
||||
return { ok: false, error: message };
|
||||
const failure = apiFailure(data);
|
||||
return { ok: false, error: failure.error };
|
||||
}
|
||||
|
||||
return { ok: true, data: data as T };
|
||||
|
||||
@ -1,7 +1,42 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth, readCSRFToken } from '$lib/auth.svelte';
|
||||
|
||||
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
|
||||
export type ApiFailure = {
|
||||
ok: false;
|
||||
/** Human-readable message, ready to show in a toast. */
|
||||
error: string;
|
||||
/** Machine-readable error code, e.g. "sandbox_not_running". */
|
||||
code?: string;
|
||||
/** Server request ID — quote it when reporting issues. */
|
||||
requestId?: string;
|
||||
/** Whether retrying the same request may succeed. */
|
||||
retryable?: boolean;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ApiResult<T> = { ok: true; data: T } | ApiFailure;
|
||||
|
||||
/**
|
||||
* Normalizes the API error envelope
|
||||
* `{"error":{code,message,request_id,retryable,details}}` into an ApiFailure.
|
||||
* Internal errors get the request ID appended so users can quote it.
|
||||
*/
|
||||
export function apiFailure(data: unknown, fallback = 'Something went wrong'): ApiFailure {
|
||||
const e = (data as { error?: Record<string, unknown> } | null)?.error;
|
||||
let message = typeof e?.message === 'string' && e.message ? e.message : fallback;
|
||||
const requestId = typeof e?.request_id === 'string' ? e.request_id : undefined;
|
||||
if (requestId && e?.code === 'internal_error') {
|
||||
message += ` (ref: ${requestId})`;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: message,
|
||||
code: typeof e?.code === 'string' ? e.code : undefined,
|
||||
requestId,
|
||||
retryable: e?.retryable === true,
|
||||
details: (e?.details as Record<string, unknown> | undefined) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function parseResponse<T>(res: Response): Promise<ApiResult<T>> {
|
||||
if (res.status === 204 || res.status === 202) {
|
||||
@ -19,8 +54,17 @@ async function parseResponse<T>(res: Response): Promise<ApiResult<T>> {
|
||||
}
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
// Non-JSON body (e.g. a 502/504 from the proxy when the control plane
|
||||
// is unreachable). Surface the status rather than masking it as a
|
||||
// connection failure in the caller's catch.
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
||||
return { ok: true, data: undefined as T };
|
||||
}
|
||||
if (!res.ok) return apiFailure(data);
|
||||
return { ok: true, data: data as T };
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { readCSRFToken } from '$lib/auth.svelte';
|
||||
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||
import { apiFailure, apiFetch, type ApiResult } from '$lib/api/client';
|
||||
|
||||
export type FileEntry = {
|
||||
name: string;
|
||||
@ -78,7 +78,8 @@ export async function readFile(
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
return { ok: false, error: data?.error?.message ?? 'Failed to read file' };
|
||||
const failure = apiFailure(data, 'Failed to read file');
|
||||
return { ok: false, error: failure.error };
|
||||
} catch {
|
||||
return { ok: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
@ -114,7 +115,15 @@ export async function downloadFile(
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
if (!res.ok) {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
throw new Error(`Download failed (HTTP ${res.status})`);
|
||||
}
|
||||
throw new Error(apiFailure(data, 'Download failed').error);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { apiFetch } from './client';
|
||||
import { apiFetch, type ApiFailure } from './client';
|
||||
|
||||
export type Host = {
|
||||
id: string;
|
||||
@ -55,11 +55,11 @@ export async function createHost(
|
||||
export async function deleteHost(
|
||||
id: string,
|
||||
force = false
|
||||
): Promise<{ ok: true } | { ok: false; error: string; sandbox_ids?: string[] }> {
|
||||
): Promise<{ ok: true } | ApiFailure> {
|
||||
const url = `/api/v1/hosts/${id}${force ? '?force=true' : ''}`;
|
||||
const res = await apiFetch<void>('DELETE', url);
|
||||
if (!res.ok) {
|
||||
return res as { ok: false; error: string };
|
||||
return res;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
};
|
||||
|
||||
const ACTIONS_BY_RESOURCE: Record<string, string[]> = {
|
||||
sandbox: ['create', 'pause', 'resume', 'destroy'],
|
||||
sandbox: ['create', 'pause', 'resume', 'destroy', 'error'],
|
||||
snapshot: ['create', 'delete'],
|
||||
template: ['delete'],
|
||||
build: ['create', 'cancel'],
|
||||
@ -56,6 +56,7 @@
|
||||
pause: 'Paused',
|
||||
resume: 'Resumed',
|
||||
destroy: 'Destroyed',
|
||||
error: 'Error',
|
||||
delete: 'Deleted',
|
||||
rename: 'Renamed',
|
||||
revoke: 'Revoked',
|
||||
@ -207,6 +208,9 @@
|
||||
const DELETED_BADGE = '\x00DELETED\x00';
|
||||
const deletedBadgeHtml = '<span class="deleted-user-badge">deleted-user</span>';
|
||||
|
||||
// The interpolated fields (user/API-key/team names, emails) are constrained
|
||||
// on the backend to an HTML-safe charset — no < > & " ' can be stored — so
|
||||
// the only markup here is the trusted DELETED_BADGE swap.
|
||||
function renderDeleted(text: string): string {
|
||||
return text.replaceAll(DELETED_BADGE, deletedBadgeHtml);
|
||||
}
|
||||
@ -219,6 +223,10 @@
|
||||
case 'sandbox:pause': return `${actor} paused a capsule`;
|
||||
case 'sandbox:resume': return `${actor} resumed a capsule`;
|
||||
case 'sandbox:destroy': return `${actor} destroyed a capsule`;
|
||||
case 'sandbox:error':
|
||||
if (meta.phase === 'resume') return 'A capsule failed to resume';
|
||||
if (meta.phase === 'create') return 'A capsule failed to start';
|
||||
return 'A capsule encountered an error';
|
||||
case 'snapshot:create': return `${actor} created a snapshot`;
|
||||
case 'snapshot:delete': return `${actor} deleted a snapshot`;
|
||||
case 'template:delete': return `${actor} deleted template "${log.resource_id}"`;
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
};
|
||||
|
||||
const ACTIONS_BY_RESOURCE: Record<string, string[]> = {
|
||||
sandbox: ['create', 'pause', 'resume', 'destroy'],
|
||||
sandbox: ['create', 'pause', 'resume', 'destroy', 'error'],
|
||||
snapshot: ['create', 'delete'],
|
||||
team: ['rename'],
|
||||
api_key: ['create', 'revoke'],
|
||||
@ -50,6 +50,7 @@
|
||||
pause: 'Paused',
|
||||
resume: 'Resumed',
|
||||
destroy: 'Destroyed',
|
||||
error: 'Error',
|
||||
delete: 'Deleted',
|
||||
rename: 'Renamed',
|
||||
revoke: 'Revoked',
|
||||
@ -195,6 +196,9 @@
|
||||
const DELETED_BADGE = '\x00DELETED\x00';
|
||||
const deletedBadgeHtml = '<span class="deleted-user-badge">deleted-user</span>';
|
||||
|
||||
// The interpolated fields (user/API-key/team names, emails) are constrained
|
||||
// on the backend to an HTML-safe charset — no < > & " ' can be stored — so
|
||||
// the only markup here is the trusted DELETED_BADGE swap.
|
||||
function renderDeleted(text: string): string {
|
||||
return text.replaceAll(DELETED_BADGE, deletedBadgeHtml);
|
||||
}
|
||||
@ -207,6 +211,10 @@
|
||||
case 'sandbox:pause': return `${actor} paused a capsule`;
|
||||
case 'sandbox:resume': return `${actor} resumed a capsule`;
|
||||
case 'sandbox:destroy': return `${actor} destroyed a capsule`;
|
||||
case 'sandbox:error':
|
||||
if (meta.phase === 'resume') return 'A capsule failed to resume';
|
||||
if (meta.phase === 'create') return 'A capsule failed to start';
|
||||
return 'A capsule encountered an error';
|
||||
case 'snapshot:create': return `${actor} created a template`;
|
||||
case 'snapshot:delete': return `${actor} deleted a template`;
|
||||
case 'team:rename': return `${actor} renamed the team from "${meta.old_name}" to "${meta.new_name}"`;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { apiFailure } from '$lib/api/client';
|
||||
|
||||
type EndpointStatus = 'loading' | 'available' | 'not_available' | 'error';
|
||||
let status = $state<EndpointStatus>('loading');
|
||||
@ -16,7 +17,7 @@
|
||||
status = 'error';
|
||||
try {
|
||||
const data = await res.json();
|
||||
errorMsg = data?.error?.message ?? `Server returned ${res.status}`;
|
||||
errorMsg = apiFailure(data, `Server returned ${res.status}`).error;
|
||||
} catch {
|
||||
errorMsg = `Server returned ${res.status}`;
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
@ -39,17 +40,17 @@ func requireRunningSandbox(w http.ResponseWriter, r *http.Request, queries *db.Q
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return db.Sandbox{}, pgtype.UUID{}, "", false
|
||||
}
|
||||
|
||||
sb, err := queries.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
|
||||
return db.Sandbox{}, pgtype.UUID{}, "", false
|
||||
}
|
||||
if sb.Status != "running" {
|
||||
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running (status: "+sb.Status+")")
|
||||
writeErr(w, r, apperr.SandboxNotRunning.New().With("status", sb.Status))
|
||||
return db.Sandbox{}, pgtype.UUID{}, "", false
|
||||
}
|
||||
|
||||
@ -71,7 +72,7 @@ func upgradeAndAuthenticate(w http.ResponseWriter, r *http.Request) (*websocket.
|
||||
func upgradeAndAuthenticateWith(w http.ResponseWriter, r *http.Request, up *websocket.Upgrader) (*websocket.Conn, auth.AuthContext, error) {
|
||||
ac, hasAuth := auth.FromContext(r.Context())
|
||||
if !hasAuth {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "session cookie or X-API-Key required")
|
||||
writeErr(w, r, apperr.AuthSessionRequired.New())
|
||||
return nil, auth.AuthContext{}, fmt.Errorf("unauthenticated")
|
||||
}
|
||||
conn, err := up.Upgrade(w, r, nil)
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
||||
@ -143,25 +144,26 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
// Validate port.
|
||||
portNum, err := strconv.Atoi(port)
|
||||
if err != nil || portNum < 1 || portNum > 65535 {
|
||||
http.Error(w, "invalid port", http.StatusBadRequest)
|
||||
writeErr(w, r, apperr.InvalidRequest.Msg("Invalid port."))
|
||||
return
|
||||
}
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid sandbox ID", http.StatusBadRequest)
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
agentURL, err := h.proxyTarget(r.Context(), sandboxID)
|
||||
if err != nil {
|
||||
var notRunning errProxySandboxNotRunning
|
||||
switch {
|
||||
case errors.Is(err, errProxySandboxNotFound):
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
case errors.As(err, new(errProxySandboxNotRunning)):
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
|
||||
case errors.As(err, ¬Running):
|
||||
writeErr(w, r, apperr.SandboxNotRunning.New().With("status", notRunning.status))
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -196,7 +198,7 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
"error", err,
|
||||
)
|
||||
h.evictProxyCache(sandboxID)
|
||||
http.Error(w, "proxy error: "+err.Error(), http.StatusBadGateway)
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
},
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
@ -29,7 +30,7 @@ func newAdminCapsuleHandler(svc *service.SandboxService, db *db.Queries, pool *l
|
||||
func (h *adminCapsuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req createSandboxRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -48,8 +49,7 @@ func (h *adminCapsuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if sb.ID.Valid {
|
||||
h.audit.LogSandboxDestroySystem(r.Context(), id.PlatformTeamID, sb.ID, "cleanup_after_create_error", nil)
|
||||
}
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ func (h *adminCapsuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *adminCapsuleHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
sandboxes, err := h.svc.List(r.Context(), id.PlatformTeamID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -78,13 +78,13 @@ func (h *adminCapsuleHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
sb, err := h.svc.Get(r.Context(), sandboxID, id.PlatformTeamID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -98,7 +98,7 @@ func (h *adminCapsuleHandler) Destroy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -106,8 +106,7 @@ func (h *adminCapsuleHandler) Destroy(w http.ResponseWriter, r *http.Request) {
|
||||
err = h.svc.Destroy(r.Context(), sandboxID, id.PlatformTeamID)
|
||||
h.audit.LogSandboxDestroy(r.Context(), ac, sandboxID, err)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -125,22 +124,21 @@ func (h *adminCapsuleHandler) Snapshot(w http.ResponseWriter, r *http.Request) {
|
||||
sandboxIDStr := chi.URLParam(r, "id")
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
var req adminSnapshotRequest
|
||||
if r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sb, name, err := h.svc.CreateSnapshot(r.Context(), sandboxID, id.PlatformTeamID, req.Name)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
@ -81,13 +82,13 @@ func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req createAPIKeyRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.Create(r.Context(), ac.TeamID, ac.UserID, req.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create API key")
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -104,7 +105,7 @@ func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
keys, err := h.svc.ListWithCreator(r.Context(), ac.TeamID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list API keys")
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -123,12 +124,12 @@ func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
keyID, err := id.ParseAPIKeyID(keyIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid API key ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid API key ID."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(r.Context(), keyID, ac.TeamID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key")
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -8,6 +9,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
||||
@ -48,10 +50,14 @@ func parseAuditParams(r *http.Request) (before time.Time, beforeID pgtype.UUID,
|
||||
|
||||
if s := r.URL.Query().Get("limit"); s != "" {
|
||||
n, parseErr := strconv.Atoi(s)
|
||||
if parseErr != nil || n < 1 {
|
||||
if parseErr != nil {
|
||||
err = parseErr
|
||||
return
|
||||
}
|
||||
if n < 1 {
|
||||
err = fmt.Errorf("limit must be a positive integer, got %d", n)
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
|
||||
@ -107,7 +113,7 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
before, beforeID, limit, err := parseAuditParams(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid query parameters."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -121,7 +127,7 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -134,7 +140,7 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *auditHandler) AdminList(w http.ResponseWriter, r *http.Request) {
|
||||
before, beforeID, limit, err := parseAuditParams(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid query parameters."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -148,7 +154,7 @@ func (h *auditHandler) AdminList(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -19,11 +19,14 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/email"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/cpextension"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/netutil"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -186,7 +189,7 @@ func (h *authHandler) issueSession(
|
||||
email, name, role string,
|
||||
isAdmin bool,
|
||||
) error {
|
||||
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), clientIP(r))
|
||||
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), netutil.ClientIP(r))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -194,18 +197,6 @@ func (h *authHandler) issueSession(
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientIP returns the request's apparent client IP, honoring
|
||||
// X-Forwarded-For when behind a reverse proxy.
|
||||
func clientIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
if i := strings.IndexByte(fwd, ','); i > 0 {
|
||||
return strings.TrimSpace(fwd[:i])
|
||||
}
|
||||
return strings.TrimSpace(fwd)
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
type signupResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@ -214,22 +205,22 @@ type signupResponse struct {
|
||||
func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
var req signupRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if !strings.Contains(req.Email, "@") || len(req.Email) < 3 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid email address")
|
||||
if err := validate.Email(req.Email); err != nil {
|
||||
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "A valid email address is required.").With("field", "email"))
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("Password must be at least 8 characters.").With("field", "password"))
|
||||
return
|
||||
}
|
||||
if req.Name == "" || len(req.Name) > 100 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters")
|
||||
if err := validate.DisplayName(req.Name); err != nil {
|
||||
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "Name may only contain letters, numbers, spaces, and . _ - (max 100 characters).").With("field", "name"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -243,28 +234,27 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
case "inactive":
|
||||
// Unactivated user — allow re-signup after cooldown.
|
||||
if time.Since(existing.CreatedAt.Time) < signupCooldown {
|
||||
writeError(w, http.StatusConflict, "signup_cooldown",
|
||||
"an activation email was recently sent to this address — please check your inbox or try again later")
|
||||
writeErr(w, r, apperr.AuthSignupCooldown.New())
|
||||
return
|
||||
}
|
||||
// Cooldown passed — delete the old row and proceed with fresh signup.
|
||||
if err := h.db.HardDeleteUser(ctx, existing.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to clean up previous signup")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
default:
|
||||
// active, disabled, deleted — email is taken.
|
||||
writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists")
|
||||
writeErr(w, r, apperr.AuthEmailTaken.New())
|
||||
return
|
||||
}
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -278,10 +268,10 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists")
|
||||
writeErr(w, r, apperr.AuthEmailTaken.Wrap(err))
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to create user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -292,7 +282,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := h.rdb.Set(ctx, redisKey, id.FormatUserID(userID), activationTTL).Err(); err != nil {
|
||||
slog.Error("signup: failed to store activation token in redis", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create activation token")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -319,12 +309,12 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
|
||||
var req activateRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Token == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The token field is required.").With("field", "token"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -334,28 +324,28 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userIDStr, err := h.rdb.GetDel(ctx, redisKey).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_token", "activation link is invalid or has expired")
|
||||
writeErr(w, r, apperr.AuthTokenInvalid.Wrap(err))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to verify token")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := id.ParseUserID(userIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "invalid stored user ID")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
if user.Status != "inactive" {
|
||||
writeError(w, http.StatusBadRequest, "already_activated", "this account has already been activated")
|
||||
writeErr(w, r, apperr.AuthAlreadyActivated.New())
|
||||
return
|
||||
}
|
||||
|
||||
@ -365,7 +355,7 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
|
||||
Status: "active",
|
||||
}); err != nil {
|
||||
slog.Error("activate: failed to set user status", "user_id", id.FormatUserID(userID), "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to activate user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -373,7 +363,7 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
|
||||
team, role, isFirstUser, err := ensureDefaultTeam(ctx, h.db, h.pool, userID, user.Name)
|
||||
if err != nil {
|
||||
slog.Error("activate: failed to create default team", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to set up account")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -381,12 +371,12 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
|
||||
// Fire OnSignup before issuing a session — billing must succeed first.
|
||||
if err := fireOnSignup(ctx, h.authHooks, userID, team.ID, user.Email); err != nil {
|
||||
slog.Error("activate: OnSignup hook failed", "user_id", id.FormatUserID(userID), "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "signup_hook_failed", "failed to finalize account setup")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
if err := h.issueSession(w, r, userID, team.ID, user.Email, user.Name, role, isAdmin); err != nil {
|
||||
slog.Error("activate: failed to issue session", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create session")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
fireOnLogin(ctx, h.authHooks, userID)
|
||||
@ -405,13 +395,13 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
if req.Email == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "email and password are required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("Email and password are required."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -421,21 +411,21 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
slog.Warn("login failed: unknown email", "email", req.Email, "ip", r.RemoteAddr)
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
|
||||
writeErr(w, r, apperr.AuthInvalidCredentials.New())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
if !user.PasswordHash.Valid {
|
||||
slog.Warn("login failed: no password set", "email", req.Email, "ip", r.RemoteAddr)
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
|
||||
writeErr(w, r, apperr.AuthInvalidCredentials.New())
|
||||
return
|
||||
}
|
||||
if err := auth.CheckPassword(user.PasswordHash.String, req.Password); err != nil {
|
||||
slog.Warn("login failed: wrong password", "email", req.Email, "ip", r.RemoteAddr)
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
|
||||
writeErr(w, r, apperr.AuthInvalidCredentials.New())
|
||||
return
|
||||
}
|
||||
|
||||
@ -444,18 +434,18 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
// OK — proceed.
|
||||
case "inactive":
|
||||
slog.Warn("login failed: account not activated", "email", req.Email, "ip", r.RemoteAddr)
|
||||
writeError(w, http.StatusForbidden, "account_not_activated", "please check your email and activate your account before signing in")
|
||||
writeErr(w, r, apperr.AuthAccountNotActivated.New())
|
||||
return
|
||||
case "disabled":
|
||||
slog.Warn("login failed: account disabled", "email", req.Email, "ip", r.RemoteAddr)
|
||||
writeError(w, http.StatusForbidden, "account_disabled", "your account has been deactivated — contact your administrator to regain access")
|
||||
writeErr(w, r, apperr.AuthAccountDisabled.New())
|
||||
return
|
||||
case "deleted":
|
||||
slog.Warn("login failed: account deleted", "email", req.Email, "ip", r.RemoteAddr)
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
|
||||
writeErr(w, r, apperr.AuthInvalidCredentials.New())
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
|
||||
writeErr(w, r, apperr.AuthInvalidCredentials.New())
|
||||
return
|
||||
}
|
||||
|
||||
@ -463,14 +453,14 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
team, role, isFirstUser, err := ensureDefaultTeam(ctx, h.db, h.pool, user.ID, user.Name)
|
||||
if err != nil {
|
||||
slog.Error("login: failed to ensure default team", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := user.IsAdmin || isFirstUser
|
||||
if err := h.issueSession(w, r, user.ID, team.ID, user.Email, user.Name, role, isAdmin); err != nil {
|
||||
slog.Error("login: failed to issue session", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create session")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
fireOnLogin(ctx, h.authHooks, user.ID)
|
||||
@ -494,17 +484,17 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req switchTeamRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
if req.TeamID == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "team_id is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The team_id field is required.").With("field", "team_id"))
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := id.ParseTeamID(req.TeamID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team_id")
|
||||
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "The team_id field is invalid.").With("field", "team_id"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -514,14 +504,14 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
|
||||
team, err := h.db.GetTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "team not found")
|
||||
writeErr(w, r, apperr.TeamNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
if team.DeletedAt.Valid {
|
||||
writeError(w, http.StatusNotFound, "not_found", "team not found")
|
||||
writeErr(w, r, apperr.TeamNotFound.New())
|
||||
return
|
||||
}
|
||||
|
||||
@ -532,26 +522,26 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "not a member of this team")
|
||||
writeErr(w, r, apperr.Forbidden.WrapMsg(err, "You are not a member of this team."))
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up membership")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch current name from DB — JWT name is not trusted here (may be stale or empty for old tokens).
|
||||
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Rotate the SID so any leaked old cookie loses access at the moment of
|
||||
// privilege change.
|
||||
newSess, err := h.sessions.Rotate(ctx, ac.SessionID, ac.UserID, teamID, user.Email, user.Name, membership.Role, user.IsAdmin, r.UserAgent(), clientIP(r))
|
||||
newSess, err := h.sessions.Rotate(ctx, ac.SessionID, ac.UserID, teamID, user.Email, user.Name, membership.Role, user.IsAdmin, r.UserAgent(), netutil.ClientIP(r))
|
||||
if err != nil {
|
||||
slog.Error("switch team: failed to rotate session", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to switch team")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
setSessionCookies(w, newSess.RawSID, newSess.CSRFToken, isSecure(r))
|
||||
@ -582,7 +572,7 @@ func (h *authHandler) LogoutAll(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
if err := h.sessions.RevokeAllForUser(r.Context(), ac.UserID); err != nil {
|
||||
slog.Error("logout-all: revoke failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
clearSessionCookies(w, isSecure(r))
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/recipe"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
||||
@ -39,13 +40,13 @@ func (h *buildStreamHandler) Stream(w http.ResponseWriter, r *http.Request) {
|
||||
buildIDStr := chi.URLParam(r, "id")
|
||||
buildID, err := id.ParseBuildID(buildIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid build ID."))
|
||||
return
|
||||
}
|
||||
|
||||
build, err := h.db.GetTemplateBuild(r.Context(), buildID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "build not found")
|
||||
writeErr(w, r, apperr.BuildNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@ -11,6 +10,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
@ -120,18 +120,18 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(ct, "multipart/") {
|
||||
// 100 MB max for multipart (archive + JSON config).
|
||||
if err := r.ParseMultipartForm(100 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "failed to parse multipart form")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Failed to parse the multipart form."))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse JSON config from "config" field.
|
||||
configStr := r.FormValue("config")
|
||||
if configStr == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "multipart form requires a 'config' JSON field")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The config field is required in the multipart form.").With("field", "config"))
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal([]byte(configStr), &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid config JSON in multipart form")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid config JSON in the multipart form."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -143,32 +143,32 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
lr := io.LimitReader(file, maxArchiveSize+1)
|
||||
archive, err = io.ReadAll(lr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "failed to read archive file")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Failed to read the archive file."))
|
||||
return
|
||||
}
|
||||
if int64(len(archive)) > maxArchiveSize {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "invalid_request", "archive exceeds 100 MB limit")
|
||||
writeErr(w, r, apperr.PayloadTooLarge.Msg("The archive exceeds the 100 MB limit."))
|
||||
return
|
||||
}
|
||||
archiveName = header.Filename
|
||||
}
|
||||
} else {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "name is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The name field is required.").With("field", "name"))
|
||||
return
|
||||
}
|
||||
if err := validate.SafeName(req.Name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid template name: %s", err))
|
||||
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "Invalid template name.").With("field", "name"))
|
||||
return
|
||||
}
|
||||
if len(req.Recipe) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "recipe must contain at least one command")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The recipe must contain at least one command.").With("field", "recipe"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -186,7 +186,7 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to create build", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "build_error", "failed to create build")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -199,7 +199,7 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *buildHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
builds, err := h.svc.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list builds")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -217,13 +217,13 @@ func (h *buildHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
buildID, err := id.ParseBuildID(buildIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid build ID."))
|
||||
return
|
||||
}
|
||||
|
||||
build, err := h.svc.Get(r.Context(), buildID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "build not found")
|
||||
writeErr(w, r, apperr.BuildNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -234,7 +234,7 @@ func (h *buildHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
templates, err := h.db.ListTemplates(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -275,31 +275,30 @@ func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
if err := validate.SafeName(name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid template name: %s", err))
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid template name."))
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
tmpl, err := h.db.GetPlatformTemplateByName(ctx, name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "template not found")
|
||||
writeErr(w, r, apperr.TemplateNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
if layout.IsSystemTemplate(tmpl.TeamID, tmpl.ID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "system base templates cannot be deleted")
|
||||
writeErr(w, r, apperr.TemplateProtected.New())
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the files from every host before dropping the DB record, so a
|
||||
// failure leaves the template intact and retryable rather than orphaned.
|
||||
if err := deleteSnapshotEverywhere(ctx, h.db, h.pool, tmpl.TeamID, tmpl.ID); err != nil {
|
||||
writeError(w, http.StatusConflict, "delete_failed",
|
||||
"could not remove template files from all hosts: "+err.Error())
|
||||
writeErr(w, r, apperr.Conflict.WrapMsg(err, "Could not remove template files from all hosts. Try again when all hosts are online."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.DeleteTemplate(ctx, tmpl.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -314,12 +313,14 @@ func (h *buildHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
buildID, err := id.ParseBuildID(buildIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid build ID."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Cancel(r.Context(), buildID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
// Cancel returns typed apperr errors (BuildNotFound / Conflict); pass
|
||||
// them through so not-found stays 404 rather than collapsing to 409.
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/channels"
|
||||
@ -79,7 +80,7 @@ func (h *channelHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req createChannelRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -91,8 +92,7 @@ func (h *channelHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
Events: req.Events,
|
||||
})
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -112,7 +112,7 @@ func (h *channelHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
chs, err := h.svc.List(r.Context(), ac.TeamID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list channels")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -131,16 +131,16 @@ func (h *channelHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
channelID, err := id.ParseChannelID(channelIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := h.svc.Get(r.Context(), channelID, ac.TeamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "channel not found")
|
||||
writeErr(w, r, apperr.NotFound.WrapMsg(err, "Channel not found."))
|
||||
} else {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get channel")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -155,20 +155,19 @@ func (h *channelHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
channelID, err := id.ParseChannelID(channelIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
|
||||
return
|
||||
}
|
||||
|
||||
var req updateChannelRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := h.svc.Update(r.Context(), channelID, ac.TeamID, req.Name, req.Events)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -180,13 +179,12 @@ func (h *channelHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *channelHandler) Test(w http.ResponseWriter, r *http.Request) {
|
||||
var req testChannelRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Test(r.Context(), req.Provider, req.Config); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -200,20 +198,19 @@ func (h *channelHandler) RotateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
channelID, err := id.ParseChannelID(channelIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
|
||||
return
|
||||
}
|
||||
|
||||
var req rotateConfigRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := h.svc.RotateConfig(r.Context(), channelID, ac.TeamID, req.Config)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -228,12 +225,12 @@ func (h *channelHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
channelID, err := id.ParseChannelID(channelIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(r.Context(), channelID, ac.TeamID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete channel")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
@ -65,18 +66,18 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req execRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Cmd == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "cmd is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The cmd field is required.").With("field", "cmd"))
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -96,8 +97,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
|
||||
Cwd: req.Cwd,
|
||||
}))
|
||||
if err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -123,8 +123,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
|
||||
Cwd: req.Cwd,
|
||||
}))
|
||||
if err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
@ -53,7 +54,7 @@ func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
||||
@ -42,35 +43,35 @@ func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(100 << 20); err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "file exceeds 100 MB limit")
|
||||
writeErr(w, r, apperr.PayloadTooLarge.Msg("File exceeds the 100 MB limit."))
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "expected multipart/form-data")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Expected multipart/form-data."))
|
||||
return
|
||||
}
|
||||
|
||||
filePath := r.FormValue("path")
|
||||
if filePath == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "path field is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "file field is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The file field is required.").With("field", "file"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "read_error", "failed to read uploaded file")
|
||||
writeErr(w, r, apperr.Internal.WrapMsg(err, "Failed to read the uploaded file."))
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -79,8 +80,7 @@ func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
Path: filePath,
|
||||
Content: content,
|
||||
})); err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -104,18 +104,18 @@ func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req readFileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -124,8 +124,7 @@ func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
Path: req.Path,
|
||||
}))
|
||||
if err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
||||
@ -40,7 +41,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
_, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || params["boundary"] == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "expected multipart/form-data with boundary")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Expected multipart/form-data with a boundary."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -56,7 +57,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "failed to parse multipart")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Malformed multipart request body."))
|
||||
return
|
||||
}
|
||||
switch part.FormName() {
|
||||
@ -72,18 +73,18 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
if filePath == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "path field is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
|
||||
return
|
||||
}
|
||||
if filePart == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "file field is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The file field is required.").With("field", "file"))
|
||||
return
|
||||
}
|
||||
defer filePart.Close()
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -105,7 +106,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "agent_error", "failed to send file metadata")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -119,7 +120,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
|
||||
if sendErr := stream.Send(&pb.WriteFileStreamRequest{
|
||||
Content: &pb.WriteFileStreamRequest_Chunk{Chunk: chunk},
|
||||
}); sendErr != nil {
|
||||
writeError(w, http.StatusBadGateway, "agent_error", "failed to stream file chunk")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(sendErr))
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -127,7 +128,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "read_error", "failed to read uploaded file")
|
||||
writeErr(w, r, apperr.Internal.WrapMsg(err, "Failed to read the uploaded file."))
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -135,8 +136,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
|
||||
// Close and receive response.
|
||||
streamClosed = true
|
||||
if _, err := stream.CloseAndReceive(); err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -156,17 +156,17 @@ func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
var req readFileRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -176,8 +176,7 @@ func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Reque
|
||||
Path: req.Path,
|
||||
}))
|
||||
if err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
||||
@ -66,17 +67,17 @@ func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req listDirRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -86,8 +87,7 @@ func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
|
||||
Depth: req.Depth,
|
||||
}))
|
||||
if err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -111,17 +111,17 @@ func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req makeDirRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -130,8 +130,7 @@ func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
|
||||
Path: req.Path,
|
||||
}))
|
||||
if err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -150,17 +149,17 @@ func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req removeRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -168,8 +167,7 @@ func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
SandboxId: sandboxIDStr,
|
||||
Path: req.Path,
|
||||
})); err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
@ -187,7 +188,7 @@ func (h *hostHandler) isAdmin(r *http.Request, userID pgtype.UUID) bool {
|
||||
func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req createHostRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -203,7 +204,7 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if req.TeamID != "" {
|
||||
teamID, err := id.ParseTeamID(req.TeamID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team_id")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID.").With("field", "team_id"))
|
||||
return
|
||||
}
|
||||
params.TeamID = teamID
|
||||
@ -211,8 +212,7 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := h.svc.Create(r.Context(), params)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -232,7 +232,7 @@ func (h *hostHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hosts, err := h.svc.List(r.Context(), ac.TeamID, admin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list hosts")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -284,14 +284,13 @@ func (h *hostHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hostID, err := id.ParseHostID(hostIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
|
||||
return
|
||||
}
|
||||
|
||||
host, err := h.svc.Get(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -306,14 +305,13 @@ func (h *hostHandler) DeletePreview(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hostID, err := id.ParseHostID(hostIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := h.svc.DeletePreview(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -333,7 +331,7 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hostID, err := id.ParseHostID(hostIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -353,18 +351,11 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if it's a "has running sandboxes" error and return a structured 409.
|
||||
var hasSandboxes *service.HostHasSandboxesError
|
||||
if errors.As(err, &hasSandboxes) {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "has_active_sandboxes",
|
||||
"message": "host has active sandboxes; use ?force=true to destroy them and delete the host",
|
||||
"sandbox_ids": hasSandboxes.SandboxIDs,
|
||||
},
|
||||
})
|
||||
writeErr(w, r, apperr.HostHasActiveSandboxes.Wrap(err).With("sandbox_ids", hasSandboxes.SandboxIDs))
|
||||
return
|
||||
}
|
||||
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
}
|
||||
|
||||
// AdminList handles GET /v1/admin/hosts.
|
||||
@ -372,7 +363,7 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *hostHandler) AdminList(w http.ResponseWriter, r *http.Request) {
|
||||
hosts, err := h.svc.ListAdmin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list hosts")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -422,14 +413,13 @@ func (h *hostHandler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hostID, err := id.ParseHostID(hostIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.RegenerateToken(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID))
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -443,16 +433,16 @@ func (h *hostHandler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *hostHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerHostRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Token == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The token field is required.").With("field", "token"))
|
||||
return
|
||||
}
|
||||
if req.Address == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "address is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The address field is required.").With("field", "address"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -465,8 +455,7 @@ func (h *hostHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
Address: req.Address,
|
||||
})
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -487,13 +476,13 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hostID, err := id.ParseHostID(hostIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent a host from heartbeating for a different host.
|
||||
if hostID != hc.HostID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "host ID mismatch")
|
||||
writeErr(w, r, apperr.Forbidden.Msg("Host ID does not match the authenticated host."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -501,8 +490,7 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
prevHost, _ := h.queries.GetHost(r.Context(), hc.HostID)
|
||||
|
||||
if err := h.svc.Heartbeat(r.Context(), hc.HostID); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -525,23 +513,22 @@ func (h *hostHandler) AddTag(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hostID, err := id.ParseHostID(hostIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
|
||||
return
|
||||
}
|
||||
|
||||
var req addTagRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
if req.Tag == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "tag is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The tag field is required.").With("field", "tag"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.AddTag(r.Context(), hostID, ac.TeamID, admin, req.Tag); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -556,13 +543,12 @@ func (h *hostHandler) RemoveTag(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hostID, err := id.ParseHostID(hostIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.RemoveTag(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID), tag); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -574,18 +560,17 @@ func (h *hostHandler) RemoveTag(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *hostHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req refreshTokenRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
if req.RefreshToken == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "refresh_token is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The refresh_token field is required.").With("field", "refresh_token"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.Refresh(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -606,14 +591,13 @@ func (h *hostHandler) ListTags(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hostID, err := id.ParseHostID(hostIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := h.svc.ListTags(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -15,6 +14,7 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/email"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
|
||||
@ -22,6 +22,7 @@ import (
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -119,13 +120,13 @@ func (h *meHandler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -154,13 +155,13 @@ func (h *meHandler) UpdateName(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req updateNameRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" || len(req.Name) > 100 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters")
|
||||
if err := validate.DisplayName(req.Name); err != nil {
|
||||
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "Name may only contain letters, numbers, spaces, and . _ - (max 100 characters).").With("field", "name"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -168,7 +169,7 @@ func (h *meHandler) UpdateName(w http.ResponseWriter, r *http.Request) {
|
||||
ID: ac.UserID,
|
||||
Name: req.Name,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to update name")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -188,46 +189,46 @@ func (h *meHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req changePasswordRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
if user.PasswordHash.Valid {
|
||||
// Changing existing password — verify current.
|
||||
if req.CurrentPassword == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "current_password is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The current_password field is required.").With("field", "current_password"))
|
||||
return
|
||||
}
|
||||
if err := auth.CheckPassword(user.PasswordHash.String, req.CurrentPassword); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "wrong_password", "current password is incorrect")
|
||||
writeErr(w, r, apperr.AuthInvalidCredentials.WrapMsg(err, "Current password is incorrect."))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// OAuth user adding a password — confirm must match.
|
||||
if req.ConfirmPassword == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "confirm_password is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The confirm_password field is required.").With("field", "confirm_password"))
|
||||
return
|
||||
}
|
||||
if req.NewPassword != req.ConfirmPassword {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "passwords do not match")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("Passwords do not match.").With("field", "confirm_password"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("Password must be at least 8 characters.").With("field", "new_password"))
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -235,7 +236,7 @@ func (h *meHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
ID: ac.UserID,
|
||||
PasswordHash: pgtype.Text{String: hash, Valid: true},
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to update password")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -328,16 +329,16 @@ func (h *meHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request)
|
||||
func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var req confirmPasswordResetRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Token == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The token field is required.").With("field", "token"))
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("Password must be at least 8 characters.").With("field", "new_password"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -349,29 +350,29 @@ func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request)
|
||||
// preventing concurrent requests from both consuming the same token.
|
||||
userIDStr, err := h.rdb.GetDel(ctx, redisKey).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_token", "reset token is invalid or has expired")
|
||||
writeErr(w, r, apperr.AuthTokenInvalid.Wrap(err))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to verify token")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := id.ParseUserID(userIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "invalid stored user ID")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -379,7 +380,7 @@ func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request)
|
||||
ID: userID,
|
||||
PasswordHash: pgtype.Text{String: hash, Valid: true},
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to update password")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -411,13 +412,13 @@ func (h *meHandler) ConnectProvider(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
p, ok := h.oauthRegistry.Get(provider)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
|
||||
writeErr(w, r, apperr.NotFound.Msg("Unsupported OAuth provider."))
|
||||
return
|
||||
}
|
||||
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate state")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -455,19 +456,19 @@ func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the user will still have at least one login method after disconnecting.
|
||||
if !user.PasswordHash.Valid && len(providers) <= 1 {
|
||||
writeError(w, http.StatusBadRequest, "last_login_method", "cannot disconnect your only login method — add a password first")
|
||||
writeErr(w, r, apperr.InvalidRequest.Msg("You cannot disconnect your only login method — add a password first."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -480,7 +481,7 @@ func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "not_found", "provider not connected")
|
||||
writeErr(w, r, apperr.NotFound.Msg("This provider is not connected to your account."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -488,7 +489,7 @@ func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: ac.UserID,
|
||||
Provider: provider,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to disconnect provider")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -502,29 +503,28 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req deleteAccountRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.EqualFold(strings.TrimSpace(req.Confirmation), user.Email) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "confirmation does not match your email address")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("Confirmation does not match your email address.").With("field", "confirmation"))
|
||||
return
|
||||
}
|
||||
|
||||
teamsBlocking, err := h.db.CountUserOwnedTeamsWithOtherMembers(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to check team ownership")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
if teamsBlocking > 0 {
|
||||
writeError(w, http.StatusConflict, "owns_team_with_members",
|
||||
fmt.Sprintf("you own %d team(s) with other members — transfer ownership or remove members before deleting your account", teamsBlocking))
|
||||
writeErr(w, r, apperr.Conflict.Msgf("You own %d team(s) with other members — transfer ownership or remove members before deleting your account.", teamsBlocking).With("owned_teams_with_members", teamsBlocking))
|
||||
return
|
||||
}
|
||||
|
||||
@ -534,20 +534,19 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
// DB-only cleanup in a transaction.
|
||||
soleTeams, err := h.db.ListSoleOwnedTeams(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list owned teams")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
for _, teamID := range soleTeams {
|
||||
if err := h.teamSvc.DeleteTeamInternal(ctx, teamID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error",
|
||||
fmt.Sprintf("failed to delete sole-owned team %s", id.FormatTeamID(teamID)))
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := h.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to start transaction")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
@ -555,17 +554,17 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
qtx := h.db.WithTx(tx)
|
||||
|
||||
if err := qtx.DeleteAPIKeysByCreator(ctx, ac.UserID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete user's API keys")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := qtx.SoftDeleteUser(ctx, ac.UserID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete account")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to commit account deletion")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -9,6 +8,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
@ -46,7 +46,7 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -56,13 +56,13 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
validRanges := map[string]bool{"5m": true, "10m": true, "1h": true, "2h": true, "6h": true, "12h": true, "24h": true}
|
||||
if !validRanges[rangeTier] {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "range must be one of: 5m, 10m, 1h, 2h, 6h, 12h, 24h")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The range parameter must be one of: 5m, 10m, 1h, 2h, 6h, 12h, 24h.").With("field", "range"))
|
||||
return
|
||||
}
|
||||
|
||||
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -70,9 +70,9 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques
|
||||
case "running":
|
||||
h.getFromAgent(w, r, sandboxIDStr, rangeTier, sb.HostID)
|
||||
case "paused":
|
||||
h.getFromDB(ctx, w, sandboxIDStr, sandboxID, rangeTier)
|
||||
h.getFromDB(w, r, sandboxIDStr, sandboxID, rangeTier)
|
||||
default:
|
||||
writeError(w, http.StatusNotFound, "not_found", "metrics not available for sandbox in state: "+sb.Status)
|
||||
writeErr(w, r, apperr.NotFound.Msg("Metrics are not available for a sandbox in state "+sb.Status+".").With("status", sb.Status))
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, hostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -90,8 +90,7 @@ func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Requ
|
||||
Range: rangeTier,
|
||||
}))
|
||||
if err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -126,7 +125,8 @@ var rangeToDB = map[string]struct {
|
||||
"24h": {"24h", 24 * time.Hour},
|
||||
}
|
||||
|
||||
func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWriter, sandboxIDStr string, sandboxID pgtype.UUID, rangeTier string) {
|
||||
func (h *sandboxMetricsHandler) getFromDB(w http.ResponseWriter, r *http.Request, sandboxIDStr string, sandboxID pgtype.UUID, rangeTier string) {
|
||||
ctx := r.Context()
|
||||
mapping := rangeToDB[rangeTier]
|
||||
rows, err := h.db.GetSandboxMetricPoints(ctx, db.GetSandboxMetricPointsParams{
|
||||
SandboxID: sandboxID,
|
||||
@ -134,7 +134,7 @@ func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWr
|
||||
Ts: time.Now().Add(-mapping.cutoff).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to read metrics")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -17,11 +17,14 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/cpextension"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/netutil"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/validate"
|
||||
)
|
||||
|
||||
type oauthHandler struct {
|
||||
@ -51,13 +54,13 @@ func (h *oauthHandler) Redirect(w http.ResponseWriter, r *http.Request) {
|
||||
provider := chi.URLParam(r, "provider")
|
||||
p, ok := h.registry.Get(provider)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
|
||||
writeErr(w, r, apperr.NotFound.Msg("Unsupported OAuth provider."))
|
||||
return
|
||||
}
|
||||
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate state")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -88,7 +91,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
provider := chi.URLParam(r, "provider")
|
||||
p, ok := h.registry.Get(provider)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
|
||||
writeErr(w, r, apperr.NotFound.Msg("Unsupported OAuth provider."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -270,6 +273,18 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A brand-new account: the email and provider-supplied name enter our
|
||||
// database here. Validate the email, and coerce the name into the safe
|
||||
// display-name charset (OAuth names cannot be rejected interactively, so
|
||||
// they are cleaned rather than refused).
|
||||
if err := validate.Email(email); err != nil {
|
||||
slog.Warn("oauth: provider returned an invalid email", "provider", provider)
|
||||
redirectWithError(w, r, redirectBase, "invalid_email")
|
||||
return
|
||||
}
|
||||
emailLocal, _, _ := strings.Cut(email, "@")
|
||||
displayName := validate.SanitizeDisplayName(profile.Name, validate.SanitizeDisplayName(emailLocal, "user"))
|
||||
|
||||
// New OAuth identity — check for email collision.
|
||||
existingUser, err := h.db.GetUserByEmail(ctx, email)
|
||||
if err == nil {
|
||||
@ -315,7 +330,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
_, err = qtx.InsertUserOAuth(ctx, db.InsertUserOAuthParams{
|
||||
ID: userID,
|
||||
Email: email,
|
||||
Name: profile.Name,
|
||||
Name: displayName,
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
@ -332,7 +347,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
teamID := id.NewTeamID()
|
||||
teamName := profile.Name + "'s Team"
|
||||
teamName := displayName + "'s Team"
|
||||
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{
|
||||
ID: teamID,
|
||||
Name: teamName,
|
||||
@ -397,7 +412,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
Secure: isSecure(r),
|
||||
})
|
||||
|
||||
if err := h.issueSessionAndRedirect(w, r, userID, teamID, email, profile.Name, "owner", isFirstUser, redirectBase); err != nil {
|
||||
if err := h.issueSessionAndRedirect(w, r, userID, teamID, email, displayName, "owner", isFirstUser, redirectBase); err != nil {
|
||||
slog.Error("oauth: failed to issue session", "error", err)
|
||||
redirectWithError(w, r, redirectBase, "internal_error")
|
||||
return
|
||||
@ -461,7 +476,7 @@ func (h *oauthHandler) issueSessionAndRedirect(
|
||||
isAdmin bool,
|
||||
redirectBase string,
|
||||
) error {
|
||||
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), clientIP(r))
|
||||
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), netutil.ClientIP(r))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
@ -52,7 +53,7 @@ func (h *processHandler) ListProcesses(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -60,8 +61,7 @@ func (h *processHandler) ListProcesses(w http.ResponseWriter, r *http.Request) {
|
||||
SandboxId: sandboxIDStr,
|
||||
}))
|
||||
if err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ func (h *processHandler) KillProcess(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -112,8 +112,7 @@ func (h *processHandler) KillProcess(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if _, err := agent.KillProcess(ctx, connect.NewRequest(killReq)); err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -128,7 +127,7 @@ func (h *processHandler) ConnectProcess(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
@ -108,7 +109,7 @@ func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
@ -84,13 +85,13 @@ func sandboxToResponse(sb db.Sandbox) sandboxResponse {
|
||||
func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req createSandboxRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
if !ac.TeamID.Valid {
|
||||
writeError(w, http.StatusForbidden, "no_team", "no active team context; re-authenticate")
|
||||
writeErr(w, r, apperr.Forbidden.Msg("No active team context; re-authenticate."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -107,8 +108,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if sb.ID.Valid {
|
||||
h.audit.LogSandboxDestroySystem(r.Context(), ac.TeamID, sb.ID, "cleanup_after_create_error", nil)
|
||||
}
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -120,7 +120,7 @@ func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
sandboxes, err := h.svc.List(r.Context(), ac.TeamID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -139,13 +139,13 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
sb, err := h.svc.Get(r.Context(), sandboxID, ac.TeamID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -167,15 +167,14 @@ func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
sb, err := h.svc.Pause(r.Context(), sandboxID, ac.TeamID)
|
||||
h.audit.LogSandboxPause(r.Context(), ac, sandboxID, err)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -189,15 +188,14 @@ func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
sb, err := h.svc.Resume(r.Context(), sandboxID, ac.TeamID)
|
||||
h.audit.LogSandboxResume(r.Context(), ac, sandboxID, err)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -211,13 +209,12 @@ func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Ping(r.Context(), sandboxID, ac.TeamID); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -231,15 +228,14 @@ func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
|
||||
err = h.svc.Destroy(r.Context(), sandboxID, ac.TeamID)
|
||||
h.audit.LogSandboxDestroy(r.Context(), ac, sandboxID, err)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/channels"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
@ -42,19 +43,19 @@ type sandboxEventRequest struct {
|
||||
func (h *sandboxEventHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
var req sandboxEventRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Event == "" || req.SandboxID == "" || req.HostID == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "event, sandbox_id, and host_id are required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The event, sandbox_id, and host_id fields are required."))
|
||||
return
|
||||
}
|
||||
|
||||
hc := auth.MustHostFromContext(r.Context())
|
||||
callerHostID := id.FormatHostID(hc.HostID)
|
||||
if callerHostID != req.HostID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "host_id does not match authenticated host")
|
||||
writeErr(w, r, apperr.Forbidden.Msg("The host_id does not match the authenticated host."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
|
||||
)
|
||||
@ -26,7 +27,7 @@ func (h *sessionsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.sessions.ListForUser(r.Context(), ac.UserID)
|
||||
if err != nil {
|
||||
slog.Error("list sessions: db error", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sessions")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
out := make([]sessionRow, 0, len(rows))
|
||||
@ -51,12 +52,12 @@ func (h *sessionsHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
sid := chi.URLParam(r, "id")
|
||||
if sid == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "missing session id")
|
||||
writeErr(w, r, apperr.InvalidRequest.Msg("The session id is required."))
|
||||
return
|
||||
}
|
||||
if err := h.sessions.DeleteForUser(r.Context(), sid, ac.UserID); err != nil {
|
||||
slog.Error("delete session: db error", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete session")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
if sid == ac.SessionID {
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
@ -119,16 +120,16 @@ type createSnapshotRequest struct {
|
||||
func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req createSnapshotRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
if req.SandboxID == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "sandbox_id is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The sandbox_id field is required.").With("field", "sandbox_id"))
|
||||
return
|
||||
}
|
||||
sandboxID, err := id.ParseSandboxID(req.SandboxID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
|
||||
return
|
||||
}
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
@ -138,8 +139,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
// result via the SSE template.snapshot.create event (or by polling).
|
||||
sb, name, err := h.sandboxSvc.CreateSnapshot(r.Context(), sandboxID, ac.TeamID, req.Name)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
h.audit.LogSnapshotCreateRequested(r.Context(), ac, name)
|
||||
@ -154,7 +154,7 @@ func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
templates, err := h.svc.List(r.Context(), ac.TeamID, typeFilter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -175,7 +175,7 @@ func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
if err := validate.SafeName(name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid snapshot name: %s", err))
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid snapshot name."))
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@ -183,29 +183,28 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tmpl, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: ac.TeamID})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "template not found")
|
||||
writeErr(w, r, apperr.TemplateNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
// Platform templates can only be deleted by admins via /v1/admin/templates.
|
||||
if tmpl.TeamID == id.PlatformTeamID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "platform templates cannot be deleted here")
|
||||
writeErr(w, r, apperr.TemplateProtected.Msg("Platform templates cannot be deleted here."))
|
||||
return
|
||||
}
|
||||
if layout.IsSystemTemplate(tmpl.TeamID, tmpl.ID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "system base templates cannot be deleted")
|
||||
writeErr(w, r, apperr.TemplateProtected.New())
|
||||
return
|
||||
}
|
||||
|
||||
if err := deleteSnapshotEverywhere(ctx, h.db, h.pool, tmpl.TeamID, tmpl.ID); err != nil {
|
||||
h.audit.LogSnapshotDelete(r.Context(), ac, name, err)
|
||||
writeError(w, http.StatusConflict, "delete_failed",
|
||||
"could not remove snapshot files from all hosts: "+err.Error())
|
||||
writeErr(w, r, apperr.Conflict.WrapMsg(err, "Could not remove snapshot files from all hosts. Try again when all hosts are online."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: name, TeamID: ac.TeamID}); err != nil {
|
||||
h.audit.LogSnapshotDelete(r.Context(), ac, name, err)
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
)
|
||||
@ -25,7 +26,7 @@ func newSSEHandler(broker *SSEBroker) *sseHandler {
|
||||
func (h *sseHandler) Stream(w http.ResponseWriter, r *http.Request) {
|
||||
ac, ok := auth.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "session cookie or X-API-Key required")
|
||||
writeErr(w, r, apperr.AuthSessionRequired.New())
|
||||
return
|
||||
}
|
||||
h.serveSSE(w, r, id.FormatTeamID(ac.TeamID), false)
|
||||
@ -36,7 +37,7 @@ func (h *sseHandler) Stream(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *sseHandler) AdminStream(w http.ResponseWriter, r *http.Request) {
|
||||
ac, ok := auth.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "admin session required")
|
||||
writeErr(w, r, apperr.Unauthorized.Msg("An admin session is required."))
|
||||
return
|
||||
}
|
||||
h.serveSSE(w, r, id.FormatTeamID(ac.TeamID), true)
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
||||
)
|
||||
@ -53,14 +54,14 @@ func (h *statsHandler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
tr := service.TimeRange(rangeParam)
|
||||
if !service.ValidRange(tr) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "range must be one of: 5m, 1h, 6h, 24h, 30d")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The range parameter must be one of: 5m, 1h, 6h, 24h, 30d.").With("field", "range"))
|
||||
return
|
||||
}
|
||||
|
||||
current, peaks, series, err := h.svc.GetStats(r.Context(), ac.TeamID, tr)
|
||||
if err != nil {
|
||||
slog.Error("stats handler: get stats failed", "team_id", ac.TeamID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to retrieve stats")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/email"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
|
||||
@ -84,11 +85,11 @@ func requireTeamAccess(w http.ResponseWriter, r *http.Request, ac auth.AuthConte
|
||||
teamIDStr := chi.URLParam(r, "id")
|
||||
teamID, err := id.ParseTeamID(teamIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID."))
|
||||
return pgtype.UUID{}, false
|
||||
}
|
||||
if ac.TeamID != teamID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "JWT team does not match requested team; use switch-team first")
|
||||
writeErr(w, r, apperr.Forbidden.Msg("Your active team does not match the requested team. Switch teams first."))
|
||||
return pgtype.UUID{}, false
|
||||
}
|
||||
return teamID, true
|
||||
@ -101,8 +102,7 @@ func (h *teamHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
teams, err := h.svc.ListTeamsForUser(r.Context(), ac.UserID)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -125,15 +125,14 @@ func (h *teamHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
|
||||
team, err := h.svc.CreateTeam(r.Context(), ac.UserID, req.Name)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -163,15 +162,13 @@ func (h *teamHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
team, err := h.svc.GetTeam(r.Context(), teamID)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
members, err := h.svc.GetMembers(r.Context(), teamID)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -199,7 +196,7 @@ func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
@ -211,8 +208,7 @@ func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.svc.RenameTeam(r.Context(), teamID, ac.UserID, req.Name); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -230,8 +226,7 @@ func (h *teamHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteTeam(r.Context(), teamID, ac.UserID); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -248,8 +243,7 @@ func (h *teamHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
members, err := h.svc.GetMembers(r.Context(), teamID)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -273,19 +267,18 @@ func (h *teamHandler) AddMember(w http.ResponseWriter, r *http.Request) {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
if req.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "email is required")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The email field is required.").With("field", "email"))
|
||||
return
|
||||
}
|
||||
|
||||
member, err := h.svc.AddMember(r.Context(), teamID, ac.UserID, req.Email)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -324,13 +317,12 @@ func (h *teamHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
targetUserID, err := id.ParseUserID(targetUserIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.RemoveMember(r.Context(), teamID, ac.UserID, targetUserID); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -350,7 +342,7 @@ func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
targetUserID, err := id.ParseUserID(targetUserIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -358,13 +350,12 @@ func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateMemberRole(r.Context(), teamID, ac.UserID, targetUserID, req.Role); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -387,8 +378,7 @@ func (h *teamHandler) Leave(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.svc.LeaveTeam(r.Context(), teamID, ac.UserID); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -404,7 +394,7 @@ func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
teamID, err := id.ParseTeamID(teamIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -412,13 +402,12 @@ func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SetBYOC(r.Context(), teamID, req.Enabled); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -440,8 +429,7 @@ func (h *teamHandler) AdminListTeams(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
teams, total, err := h.svc.AdminListTeams(r.Context(), perPage, offset)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -502,13 +490,12 @@ func (h *teamHandler) AdminDeleteTeam(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
teamID, err := id.ParseTeamID(teamIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID."))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.AdminDeleteTeam(r.Context(), teamID); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
||||
)
|
||||
@ -41,7 +42,7 @@ func (h *usageHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
from, err = time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "from must be YYYY-MM-DD")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The from parameter must be YYYY-MM-DD.").With("field", "from"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@ -52,7 +53,7 @@ func (h *usageHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
to, err = time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "to must be YYYY-MM-DD")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The to parameter must be YYYY-MM-DD.").With("field", "to"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@ -60,18 +61,18 @@ func (h *usageHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if from.After(to) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "from must be before or equal to to")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The from date must be before or equal to the to date."))
|
||||
return
|
||||
}
|
||||
if to.Sub(from).Hours()/24 > 92 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "range cannot exceed 92 days")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The range cannot exceed 92 days."))
|
||||
return
|
||||
}
|
||||
|
||||
points, err := h.svc.GetUsage(r.Context(), ac.TeamID, from, to)
|
||||
if err != nil {
|
||||
slog.Error("usage handler: get usage failed", "team_id", ac.TeamID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to retrieve usage")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@ -9,6 +10,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
|
||||
@ -36,7 +38,7 @@ func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
prefix := strings.TrimSpace(r.URL.Query().Get("email"))
|
||||
if len(prefix) < 3 || !strings.Contains(prefix, "@") {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters and contain '@'")
|
||||
writeErr(w, r, apperr.ValidationFailed.Msg("The email prefix must be at least 3 characters and contain '@'.").With("field", "email"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -45,7 +47,7 @@ func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
results, err := h.db.SearchUsersByEmailPrefix(r.Context(), pgtype.Text{String: escaped, Valid: true})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", "search failed")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -74,8 +76,7 @@ func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
users, total, err := h.svc.AdminListUsers(r.Context(), perPage, offset)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -122,7 +123,7 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userID, err := id.ParseUserID(userIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -130,12 +131,12 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
if ac.UserID == userID && !req.Active {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "cannot deactivate your own account")
|
||||
writeErr(w, r, apperr.InvalidRequest.Msg("You cannot deactivate your own account."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -147,22 +148,25 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
|
||||
// Look up user email for audit log before changing status.
|
||||
user, err := h.db.GetUserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "user not found")
|
||||
writeErr(w, r, apperr.UserNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SetUserStatus(r.Context(), userID, newStatus); err != nil {
|
||||
httpStatus, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, httpStatus, code, msg)
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Active {
|
||||
h.audit.LogUserActivate(r.Context(), ac, userID, user.Email)
|
||||
} else {
|
||||
// Disabled users must be kicked out of every active session.
|
||||
// Disabled users must be kicked out of every active session. A failure
|
||||
// here leaves the account's cached sessions live (Session.Get only
|
||||
// re-checks user status on a cache miss), so it must be surfaced loudly
|
||||
// rather than swallowed — an operator may need to force revocation.
|
||||
if err := h.sessions.RevokeAllForUser(r.Context(), userID); err != nil {
|
||||
_ = err
|
||||
slog.Error("deactivate user: revoke sessions failed",
|
||||
"user_id", id.FormatUserID(userID), "error", err)
|
||||
}
|
||||
h.audit.LogUserDeactivate(r.Context(), ac, userID, user.Email)
|
||||
}
|
||||
@ -177,7 +181,7 @@ func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userID, err := id.ParseUserID(userIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
|
||||
return
|
||||
}
|
||||
|
||||
@ -185,13 +189,13 @@ func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
Admin bool `json:"admin"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "user not found")
|
||||
writeErr(w, r, apperr.UserNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
@ -205,18 +209,18 @@ func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
ID: userID,
|
||||
IsAdmin: true,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
h.audit.LogUserGrantAdmin(r.Context(), ac, userID, user.Email)
|
||||
} else {
|
||||
affected, err := h.db.RevokeUserAdmin(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status")
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
if affected == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "cannot remove the last admin")
|
||||
writeErr(w, r, apperr.InvalidRequest.Msg("Cannot remove the last admin."))
|
||||
return
|
||||
}
|
||||
h.audit.LogUserRevokeAdmin(r.Context(), ac, userID, user.Email)
|
||||
|
||||
@ -3,39 +3,29 @@ package api
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
Error errorDetail `json:"error"`
|
||||
}
|
||||
|
||||
type errorDetail struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
writeJSON(w, status, errorResponse{
|
||||
Error: errorDetail{Code: code, Message: message},
|
||||
})
|
||||
// writeErr resolves any error to its client-safe envelope and writes it,
|
||||
// logging the cause chain keyed by request ID. Handlers construct errors
|
||||
// from the pkg/apperr catalog.
|
||||
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
|
||||
apperr.WriteHTTP(w, r, err)
|
||||
}
|
||||
|
||||
// formatUUIDForRPC converts a pgtype.UUID to a hex string for RPC messages.
|
||||
@ -43,32 +33,6 @@ func formatUUIDForRPC(u pgtype.UUID) string {
|
||||
return id.UUIDString(u)
|
||||
}
|
||||
|
||||
// agentErrToHTTP maps a Connect RPC error to an HTTP status, error code, and message.
|
||||
func agentErrToHTTP(err error) (int, string, string) {
|
||||
switch connect.CodeOf(err) {
|
||||
case connect.CodeNotFound:
|
||||
return http.StatusNotFound, "not_found", err.Error()
|
||||
case connect.CodeInvalidArgument:
|
||||
return http.StatusBadRequest, "invalid_request", err.Error()
|
||||
case connect.CodeAlreadyExists:
|
||||
return http.StatusConflict, "already_exists", err.Error()
|
||||
case connect.CodeFailedPrecondition:
|
||||
return http.StatusConflict, "conflict", err.Error()
|
||||
case connect.CodePermissionDenied:
|
||||
return http.StatusForbidden, "forbidden", err.Error()
|
||||
case connect.CodeUnavailable:
|
||||
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
|
||||
case connect.CodeUnimplemented:
|
||||
return http.StatusNotImplemented, "agent_error", err.Error()
|
||||
case connect.CodeDeadlineExceeded:
|
||||
return http.StatusGatewayTimeout, "timeout", "command timed out"
|
||||
case connect.CodeInternal:
|
||||
return http.StatusInternalServerError, "agent_error", err.Error()
|
||||
default:
|
||||
return http.StatusBadGateway, "agent_error", err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// requestLogger returns middleware that logs each request.
|
||||
func requestLogger() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
@ -77,6 +41,7 @@ func requestLogger() func(http.Handler) http.Handler {
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
slog.Info("request",
|
||||
"request_id", apperr.RequestID(r.Context()),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sw.status,
|
||||
@ -90,45 +55,6 @@ func decodeJSON(r *http.Request, v any) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
// serviceErrToHTTP maps a service-layer error to an HTTP status, code, and message.
|
||||
// It inspects the underlying Connect RPC error if present, otherwise returns 500.
|
||||
func serviceErrToHTTP(err error) (int, string, string) {
|
||||
msg := err.Error()
|
||||
|
||||
// Check for Connect RPC errors wrapped by the service layer.
|
||||
var connectErr *connect.Error
|
||||
if errors.As(err, &connectErr) {
|
||||
return agentErrToHTTP(connectErr)
|
||||
}
|
||||
|
||||
// Map well-known service error patterns.
|
||||
// Return generic messages for most cases to avoid leaking internal details.
|
||||
switch {
|
||||
case strings.Contains(msg, "not found"):
|
||||
return http.StatusNotFound, "not_found", "resource not found"
|
||||
case strings.Contains(msg, "not running"):
|
||||
return http.StatusConflict, "invalid_state", "resource is not running"
|
||||
case strings.Contains(msg, "not paused"):
|
||||
return http.StatusConflict, "invalid_state", "resource is not paused"
|
||||
case strings.Contains(msg, "conflict:"):
|
||||
return http.StatusConflict, "conflict", strings.TrimPrefix(msg, "conflict: ")
|
||||
case strings.Contains(msg, "forbidden"):
|
||||
return http.StatusForbidden, "forbidden", "forbidden"
|
||||
case strings.Contains(msg, "invalid or expired"):
|
||||
return http.StatusUnauthorized, "unauthorized", "invalid or expired credentials"
|
||||
case strings.Contains(msg, "no online") && strings.Contains(msg, "hosts available"),
|
||||
strings.Contains(msg, "no host has sufficient resources"):
|
||||
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
|
||||
case strings.HasPrefix(msg, "invalid metadata: "):
|
||||
return http.StatusBadRequest, "invalid_metadata", strings.TrimPrefix(msg, "invalid metadata: ")
|
||||
case strings.Contains(msg, "invalid"):
|
||||
return http.StatusBadRequest, "invalid_request", "invalid request"
|
||||
default:
|
||||
slog.Error("unhandled service error", "error", err)
|
||||
return http.StatusInternalServerError, "internal_error", "an internal error occurred"
|
||||
}
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
|
||||
@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
@ -35,12 +36,18 @@ func requireAdmin(queries *db.Queries) func(http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ac, ok := auth.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
writeErr(w, r, apperr.Unauthorized.New())
|
||||
return
|
||||
}
|
||||
user, err := queries.GetUserByID(r.Context(), ac.UserID)
|
||||
if err != nil || !user.IsAdmin {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin access required")
|
||||
if err != nil {
|
||||
// A transient DB failure is not an authorization decision — a
|
||||
// legitimate admin must not see "Admin access required" (403).
|
||||
writeErr(w, r, apperr.Internal.Wrap(err))
|
||||
return
|
||||
}
|
||||
if !user.IsAdmin {
|
||||
writeErr(w, r, apperr.Forbidden.Msg("Admin access required."))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
)
|
||||
|
||||
@ -29,7 +30,7 @@ func requireCSRF() func(http.Handler) http.Handler {
|
||||
header := r.Header.Get("X-CSRF-Token")
|
||||
if err != nil || cookie.Value == "" || header == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(header)) != 1 {
|
||||
writeError(w, http.StatusForbidden, "csrf_failed", "missing or invalid CSRF token")
|
||||
writeErr(w, r, apperr.AuthCSRF.New())
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
)
|
||||
@ -14,19 +15,19 @@ func requireHostToken(secret []byte) func(http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenStr := r.Header.Get("X-Host-Token")
|
||||
if tokenStr == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "X-Host-Token header required")
|
||||
writeErr(w, r, apperr.Unauthorized.Msg("The X-Host-Token header is required."))
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := auth.VerifyHostJWT(secret, tokenStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired host token")
|
||||
writeErr(w, r, apperr.Unauthorized.WrapMsg(err, "Host token is invalid or has expired."))
|
||||
return
|
||||
}
|
||||
|
||||
hostID, err := id.ParseHostID(claims.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid host ID in token")
|
||||
writeErr(w, r, apperr.Unauthorized.WrapMsg(err, "Host token carries an invalid host ID."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -3718,7 +3718,7 @@ components:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
ServiceUnavailable:
|
||||
description: The host serving this capsule is unreachable (host_unavailable)
|
||||
description: Temporarily unavailable — no capacity (capacity_unavailable), host draining (host_draining), or the capsule's host is unreachable (host_unreachable, returned as 502)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@ -4426,21 +4426,13 @@ components:
|
||||
description: IDs of capsules that would be destroyed on force-delete.
|
||||
|
||||
HostHasCapsulesError:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: has_active_sandboxes
|
||||
message:
|
||||
type: string
|
||||
sandbox_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: IDs of active capsules blocking deletion.
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Error"
|
||||
description: |
|
||||
Standard error envelope returned by host delete (409) when the host
|
||||
still has active capsules. `error.code` is `has_active_sandboxes` and
|
||||
`error.details.sandbox_ids` lists the IDs of the capsules blocking
|
||||
deletion — pass `?force=true` to destroy them and delete the host.
|
||||
|
||||
AddTagRequest:
|
||||
type: object
|
||||
@ -4724,14 +4716,37 @@ components:
|
||||
|
||||
Error:
|
||||
type: object
|
||||
description: |
|
||||
Standard error envelope returned by every non-2xx JSON response.
|
||||
`code` is a stable machine-readable identifier (snake_case, e.g.
|
||||
`sandbox_not_running`, `capacity_unavailable`, `internal_error`).
|
||||
`message` is human-readable and safe to display. `request_id` matches
|
||||
the `X-Request-ID` response header and the server logs — quote it when
|
||||
reporting issues. `retryable` indicates the same request may succeed
|
||||
if retried. `details` carries optional machine-readable context
|
||||
(e.g. `{"status": "paused"}` on `sandbox_not_running`, or
|
||||
`{"field": "path"}` on `validation_failed`).
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
required: [code, message, retryable]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: sandbox_not_running
|
||||
message:
|
||||
type: string
|
||||
example: Sandbox is not running.
|
||||
request_id:
|
||||
type: string
|
||||
example: req_8f3ka92b1c04d7e6
|
||||
retryable:
|
||||
type: boolean
|
||||
example: false
|
||||
details:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
example: { status: paused }
|
||||
|
||||
AuditLogEntry:
|
||||
type: object
|
||||
|
||||
@ -307,15 +307,20 @@ func (c *SandboxEventConsumer) handleFailed(ctx context.Context, sandboxID pgtyp
|
||||
return
|
||||
}
|
||||
|
||||
action := "create"
|
||||
// The lifecycle op that failed is recorded as a distinct "error" action
|
||||
// (attributed to system) rather than a second "create"/"resume" row, so the
|
||||
// audit log reads "a capsule encountered an error" instead of masquerading
|
||||
// as a duplicate, system-attributed creation. The user-attributed create row
|
||||
// was already written at request-accept time by the handler.
|
||||
phase := "create"
|
||||
if event.Event == events.CapsuleResume {
|
||||
action = "resume"
|
||||
phase = "resume"
|
||||
}
|
||||
reason := event.Metadata["reason"]
|
||||
if reason == "" {
|
||||
reason = action + "_failed"
|
||||
reason = phase + "_failed"
|
||||
}
|
||||
meta := map[string]any{"reason": reason}
|
||||
meta := map[string]any{"reason": reason, "phase": phase}
|
||||
if event.Error != "" {
|
||||
meta["error"] = event.Error
|
||||
}
|
||||
@ -325,7 +330,7 @@ func (c *SandboxEventConsumer) handleFailed(ctx context.Context, sandboxID pgtyp
|
||||
ActorType: "system",
|
||||
ResourceType: "sandbox",
|
||||
ResourceID: id.FormatSandboxID(sandboxID),
|
||||
Action: action,
|
||||
Action: "error",
|
||||
Scope: "team",
|
||||
Status: "error",
|
||||
Metadata: meta,
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/email"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
|
||||
@ -61,6 +62,7 @@ func New(
|
||||
version string,
|
||||
) *Server {
|
||||
r := chi.NewRouter()
|
||||
r.Use(apperr.Middleware)
|
||||
r.Use(requestLogger())
|
||||
|
||||
// Apply extension middleware before routes so it wraps all OSS routes.
|
||||
|
||||
@ -155,6 +155,15 @@ func impliedSandboxStatus(event events.Event) (string, bool) {
|
||||
if event.Outcome != events.OutcomeSuccess {
|
||||
return "", false
|
||||
}
|
||||
// Only system-initiated completion events (host callbacks, TTL reaper) imply
|
||||
// a terminal status. User/API-key create/resume/pause events are published at
|
||||
// request-accept time — while the sandbox is still transient (starting /
|
||||
// resuming / pausing) — so their hydrated DB row is authoritative and must
|
||||
// not be overridden. Overriding them masks the transient state on the
|
||||
// dashboard (a launching capsule jumps straight to "running").
|
||||
if event.Actor.Type != events.ActorSystem {
|
||||
return "", false
|
||||
}
|
||||
switch event.Event {
|
||||
case events.CapsulePause:
|
||||
return "paused", true
|
||||
|
||||
@ -74,13 +74,6 @@ func LoadTokenFile(path string) (*TokenFile, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Support legacy format (raw JWT string) for backwards compatibility.
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
// Old format: just the JWT, no refresh token.
|
||||
hostID, _ := hostIDFromJWT(trimmed)
|
||||
return &TokenFile{HostID: hostID, JWT: trimmed}, nil
|
||||
}
|
||||
var tf TokenFile
|
||||
if err := json.Unmarshal(data, &tf); err != nil {
|
||||
return nil, fmt.Errorf("parse credentials file: %w", err)
|
||||
|
||||
@ -19,7 +19,9 @@ import (
|
||||
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
|
||||
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/envdclient"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/network"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/sandbox"
|
||||
)
|
||||
|
||||
@ -56,11 +58,11 @@ func parseUUIDString(s string) (pgtype.UUID, error) {
|
||||
func parseSandboxIDs(teamIDStr, templateIDStr string) (teamID, templateID pgtype.UUID, err error) {
|
||||
teamID, err = parseUUIDString(teamIDStr)
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, pgtype.UUID{}, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
return pgtype.UUID{}, pgtype.UUID{}, apperr.ToConnect(apperr.InvalidRequest.Wrap(err))
|
||||
}
|
||||
templateID, err = parseUUIDString(templateIDStr)
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, pgtype.UUID{}, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
return pgtype.UUID{}, pgtype.UUID{}, apperr.ToConnect(apperr.InvalidRequest.Wrap(err))
|
||||
}
|
||||
return teamID, templateID, nil
|
||||
}
|
||||
@ -83,10 +85,7 @@ func (s *Server) CreateSandbox(
|
||||
int(msg.Vcpus), int(msg.MemoryMb), int(msg.TimeoutSec), 0,
|
||||
msg.DefaultUser, msg.DefaultEnv)
|
||||
if err != nil {
|
||||
if errors.Is(err, sandbox.ErrDraining) {
|
||||
return nil, connect.NewError(connect.CodeUnavailable, err)
|
||||
}
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create sandbox: %w", err))
|
||||
return nil, mapSandboxError(fmt.Errorf("create sandbox: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.CreateSandboxResponse{
|
||||
@ -183,21 +182,32 @@ func (s *Server) FlattenRootfs(
|
||||
}), nil
|
||||
}
|
||||
|
||||
// mapSandboxError translates sandbox.Manager errors to Connect error codes
|
||||
// via sentinel errors (errors.Is). Adding a new precondition sentinel in the
|
||||
// sandbox package only requires extending this switch — no string sniffing.
|
||||
// mapSandboxError translates sandbox.Manager errors to Connect errors
|
||||
// carrying an apperr ErrorInfo detail, so the control plane surfaces the
|
||||
// precise catalog error instead of guessing from the Connect code. Sentinels
|
||||
// are matched with errors.Is — no string sniffing. Unmatched errors become
|
||||
// internal_error: the full cause crosses the (internal) RPC channel for CP
|
||||
// logs, while the client-safe detail stays generic.
|
||||
func mapSandboxError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, sandbox.ErrNotFound):
|
||||
return connect.NewError(connect.CodeNotFound, err)
|
||||
case errors.Is(err, sandbox.ErrNotRunning), errors.Is(err, sandbox.ErrNotPaused):
|
||||
return connect.NewError(connect.CodeFailedPrecondition, err)
|
||||
return apperr.ToConnect(apperr.SandboxNotFound.Wrap(err))
|
||||
case errors.Is(err, sandbox.ErrNotRunning):
|
||||
return apperr.ToConnect(apperr.SandboxNotRunning.Wrap(err))
|
||||
case errors.Is(err, sandbox.ErrNotPaused):
|
||||
return apperr.ToConnect(apperr.SandboxNotPaused.Wrap(err))
|
||||
case errors.Is(err, sandbox.ErrDraining):
|
||||
return connect.NewError(connect.CodeUnavailable, err)
|
||||
return apperr.ToConnect(apperr.HostDraining.Wrap(err))
|
||||
case errors.Is(err, sandbox.ErrInvalidRange):
|
||||
return connect.NewError(connect.CodeInvalidArgument, err)
|
||||
return apperr.ToConnect(apperr.InvalidRequest.WrapMsg(err, "Invalid metrics range."))
|
||||
case errors.Is(err, sandbox.ErrTemplateNotFound):
|
||||
return apperr.ToConnect(apperr.TemplateNotFound.WrapMsg(err, "The template's rootfs image is not available on this host."))
|
||||
case errors.Is(err, sandbox.ErrEnvdNotReady):
|
||||
return apperr.ToConnect(apperr.SandboxUnresponsive.WrapMsg(err, "The sandbox VM started but its agent did not become ready."))
|
||||
case errors.Is(err, network.ErrNoFreeSlots):
|
||||
return apperr.ToConnect(apperr.CapacityUnavailable.WrapMsg(err, "This host has no free network slots. Try again shortly."))
|
||||
default:
|
||||
return connect.NewError(connect.CodeInternal, err)
|
||||
return apperr.ToConnect(err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -212,9 +222,9 @@ func (s *Server) GetTemplateSize(
|
||||
size, err := s.mgr.TemplateRootfsSize(teamID, templateID)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
return nil, apperr.ToConnect(apperr.TemplateNotFound.Wrap(err))
|
||||
}
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("get template size: %w", err))
|
||||
return nil, mapSandboxError(fmt.Errorf("get template size: %w", err))
|
||||
}
|
||||
return connect.NewResponse(&pb.GetTemplateSizeResponse{
|
||||
SizeBytes: size,
|
||||
@ -226,10 +236,7 @@ func (s *Server) PingSandbox(
|
||||
req *connect.Request[pb.PingSandboxRequest],
|
||||
) (*connect.Response[pb.PingSandboxResponse], error) {
|
||||
if err := s.mgr.Ping(req.Msg.SandboxId); err != nil {
|
||||
if errors.Is(err, sandbox.ErrNotFound) {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
return nil, connect.NewError(connect.CodeFailedPrecondition, err)
|
||||
return nil, mapSandboxError(err)
|
||||
}
|
||||
return connect.NewResponse(&pb.PingSandboxResponse{}), nil
|
||||
}
|
||||
@ -265,15 +272,42 @@ func (s *Server) Exec(
|
||||
}), nil
|
||||
}
|
||||
|
||||
// envdErr propagates an error from the envd client, preserving its Connect
|
||||
// error code (e.g. AlreadyExists, NotFound) so the control plane maps it to
|
||||
// the correct HTTP status. Non-Connect errors fall back to CodeInternal.
|
||||
// envdErr propagates an error from the envd client. envd is a separate binary
|
||||
// that never attaches an apperr ErrorInfo detail, so its Connect code is the
|
||||
// only signal — and the control plane's fromConnect flattens any detail-less
|
||||
// error to internal_error. So client-fault codes are translated to their
|
||||
// catalog Def here, keeping envd's message (it describes the caller's own
|
||||
// request, e.g. a bad path inside their sandbox); without this a missing file
|
||||
// would surface as a misleading 500 instead of a 404. Transport failures
|
||||
// reaching envd become sandbox_unresponsive, everything else internal_error
|
||||
// with the cause preserved for CP logs.
|
||||
func envdErr(action string, err error) error {
|
||||
code := connect.CodeOf(err)
|
||||
if code == connect.CodeUnknown {
|
||||
code = connect.CodeInternal
|
||||
wrapped := fmt.Errorf("%s: %w", action, err)
|
||||
switch connect.CodeOf(err) {
|
||||
case connect.CodeNotFound:
|
||||
return apperr.ToConnect(apperr.NotFound.WrapMsg(wrapped, connectMessage(err)))
|
||||
case connect.CodeInvalidArgument:
|
||||
return apperr.ToConnect(apperr.InvalidRequest.WrapMsg(wrapped, connectMessage(err)))
|
||||
case connect.CodeAlreadyExists:
|
||||
return apperr.ToConnect(apperr.Conflict.WrapMsg(wrapped, connectMessage(err)))
|
||||
case connect.CodePermissionDenied:
|
||||
return apperr.ToConnect(apperr.Forbidden.Wrap(wrapped))
|
||||
case connect.CodeUnavailable:
|
||||
return apperr.ToConnect(apperr.SandboxUnresponsive.Wrap(wrapped))
|
||||
default:
|
||||
return apperr.ToConnect(wrapped)
|
||||
}
|
||||
return connect.NewError(code, fmt.Errorf("%s: %w", action, err))
|
||||
}
|
||||
|
||||
// connectMessage returns the human-readable message from a Connect error. envd
|
||||
// phrases its client-fault messages in terms of the caller's own request
|
||||
// ("path not found"), so they are safe to surface to API clients.
|
||||
func connectMessage(err error) string {
|
||||
var ce *connect.Error
|
||||
if errors.As(err, &ce) {
|
||||
return ce.Message()
|
||||
}
|
||||
return "The request could not be completed."
|
||||
}
|
||||
|
||||
func (s *Server) WriteFile(
|
||||
@ -284,7 +318,7 @@ func (s *Server) WriteFile(
|
||||
|
||||
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
return nil, mapSandboxError(err)
|
||||
}
|
||||
|
||||
if err := client.WriteFile(ctx, msg.Path, msg.Content); err != nil {
|
||||
@ -302,7 +336,7 @@ func (s *Server) ReadFile(
|
||||
|
||||
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
return nil, mapSandboxError(err)
|
||||
}
|
||||
|
||||
content, err := client.ReadFile(ctx, msg.Path)
|
||||
@ -321,7 +355,7 @@ func (s *Server) ListDir(
|
||||
|
||||
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
return nil, mapSandboxError(err)
|
||||
}
|
||||
|
||||
resp, err := client.ListDir(ctx, msg.Path, msg.Depth)
|
||||
@ -345,7 +379,7 @@ func (s *Server) MakeDir(
|
||||
|
||||
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
return nil, mapSandboxError(err)
|
||||
}
|
||||
|
||||
resp, err := client.MakeDir(ctx, msg.Path)
|
||||
@ -366,7 +400,7 @@ func (s *Server) RemovePath(
|
||||
|
||||
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
return nil, mapSandboxError(err)
|
||||
}
|
||||
|
||||
if err := client.Remove(ctx, msg.Path); err != nil {
|
||||
@ -393,7 +427,7 @@ func (s *Server) ExecStream(
|
||||
|
||||
events, err := s.mgr.ExecStream(execCtx, msg.SandboxId, msg.Cmd, msg.Args...)
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("exec stream: %w", err))
|
||||
return mapSandboxError(fmt.Errorf("exec stream: %w", err))
|
||||
}
|
||||
|
||||
for ev := range events {
|
||||
@ -442,20 +476,20 @@ func (s *Server) WriteFileStream(
|
||||
// First message must contain metadata.
|
||||
if !stream.Receive() {
|
||||
if err := stream.Err(); err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, err)
|
||||
return nil, apperr.ToConnect(err)
|
||||
}
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("empty stream"))
|
||||
return nil, apperr.ToConnect(apperr.InvalidRequest.Msg("The upload stream was empty."))
|
||||
}
|
||||
|
||||
first := stream.Msg()
|
||||
meta := first.GetMeta()
|
||||
if meta == nil {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("first message must contain metadata"))
|
||||
return nil, apperr.ToConnect(apperr.InvalidRequest.Msg("The first stream message must contain metadata."))
|
||||
}
|
||||
|
||||
client, err := s.mgr.GetClient(meta.SandboxId)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
return nil, mapSandboxError(err)
|
||||
}
|
||||
|
||||
// Use io.Pipe to stream raw chunks into envd's REST endpoint body.
|
||||
@ -484,18 +518,18 @@ func (s *Server) WriteFileStream(
|
||||
errCh <- nil
|
||||
}()
|
||||
|
||||
// Send the raw streaming body to envd.
|
||||
// Send the raw streaming body to envd. No username is sent, so envd resolves
|
||||
// the sandbox default user — the same user process execution runs as.
|
||||
base := client.BaseURL()
|
||||
u := fmt.Sprintf("%s/files?%s", base, url.Values{
|
||||
"path": {meta.Path},
|
||||
"username": {"root"},
|
||||
"path": {meta.Path},
|
||||
}.Encode())
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, u, pr)
|
||||
if err != nil {
|
||||
pw.CloseWithError(err)
|
||||
<-errCh
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create request: %w", err))
|
||||
return nil, apperr.ToConnect(fmt.Errorf("create request: %w", err))
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
@ -503,18 +537,18 @@ func (s *Server) WriteFileStream(
|
||||
if err != nil {
|
||||
pw.CloseWithError(err)
|
||||
<-errCh
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("write file stream: %w", err))
|
||||
return nil, envdErr("write file stream", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Wait for the writer goroutine.
|
||||
if writerErr := <-errCh; writerErr != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, writerErr)
|
||||
return nil, apperr.ToConnect(writerErr)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("envd write: status %d: %s", resp.StatusCode, string(body)))
|
||||
return nil, envdErr("write file stream", fmt.Errorf("envd write: status %d: %s", resp.StatusCode, string(body)))
|
||||
}
|
||||
|
||||
slog.Debug("streaming file write complete", "sandbox_id", meta.SandboxId, "path", meta.Path)
|
||||
@ -530,29 +564,30 @@ func (s *Server) ReadFileStream(
|
||||
|
||||
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeNotFound, err)
|
||||
return mapSandboxError(err)
|
||||
}
|
||||
|
||||
// No username is sent, so envd resolves the sandbox default user, matching
|
||||
// process execution.
|
||||
base := client.BaseURL()
|
||||
u := fmt.Sprintf("%s/files?%s", base, url.Values{
|
||||
"path": {msg.Path},
|
||||
"username": {"root"},
|
||||
"path": {msg.Path},
|
||||
}.Encode())
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("create request: %w", err))
|
||||
return apperr.ToConnect(fmt.Errorf("create request: %w", err))
|
||||
}
|
||||
|
||||
resp, err := client.StreamingHTTPClient().Do(httpReq)
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("read file stream: %w", err))
|
||||
return envdErr("read file stream", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("envd read: status %d: %s", resp.StatusCode, string(body)))
|
||||
return envdErr("read file stream", fmt.Errorf("envd read: status %d: %s", resp.StatusCode, string(body)))
|
||||
}
|
||||
|
||||
// Stream file content in 64KB chunks.
|
||||
@ -580,7 +615,7 @@ func (s *Server) ReadFileStream(
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("read body: %w", err))
|
||||
return envdErr("read file stream", fmt.Errorf("read body: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@ -687,7 +722,7 @@ func (s *Server) PtyAttach(
|
||||
|
||||
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd, msg.User, msg.Reconnect)
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("pty attach: %w", err))
|
||||
return mapSandboxError(fmt.Errorf("pty attach: %w", err))
|
||||
}
|
||||
|
||||
for ev := range events {
|
||||
@ -723,7 +758,7 @@ func (s *Server) PtySendInput(
|
||||
msg := req.Msg
|
||||
|
||||
if err := s.mgr.PtySendInput(ctx, msg.SandboxId, msg.Tag, msg.Data); err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty send input: %w", err))
|
||||
return nil, mapSandboxError(fmt.Errorf("pty send input: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.PtySendInputResponse{}), nil
|
||||
@ -736,7 +771,7 @@ func (s *Server) PtyResize(
|
||||
msg := req.Msg
|
||||
|
||||
if err := s.mgr.PtyResize(ctx, msg.SandboxId, msg.Tag, msg.Cols, msg.Rows); err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty resize: %w", err))
|
||||
return nil, mapSandboxError(fmt.Errorf("pty resize: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.PtyResizeResponse{}), nil
|
||||
@ -749,7 +784,7 @@ func (s *Server) PtyKill(
|
||||
msg := req.Msg
|
||||
|
||||
if err := s.mgr.PtyKill(ctx, msg.SandboxId, msg.Tag); err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty kill: %w", err))
|
||||
return nil, mapSandboxError(fmt.Errorf("pty kill: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.PtyKillResponse{}), nil
|
||||
@ -805,10 +840,7 @@ func (s *Server) StartBackground(
|
||||
|
||||
pid, err := s.mgr.StartBackground(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Envs, msg.Cwd)
|
||||
if err != nil {
|
||||
if errors.Is(err, sandbox.ErrNotFound) {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("start background: %w", err))
|
||||
return nil, mapSandboxError(fmt.Errorf("start background: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.StartBackgroundResponse{
|
||||
@ -823,10 +855,7 @@ func (s *Server) ListProcesses(
|
||||
) (*connect.Response[pb.ListProcessesResponse], error) {
|
||||
procs, err := s.mgr.ListProcesses(ctx, req.Msg.SandboxId)
|
||||
if err != nil {
|
||||
if errors.Is(err, sandbox.ErrNotFound) {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("list processes: %w", err))
|
||||
return nil, mapSandboxError(fmt.Errorf("list processes: %w", err))
|
||||
}
|
||||
|
||||
entries := make([]*pb.ProcessEntry, 0, len(procs))
|
||||
@ -859,7 +888,7 @@ func (s *Server) KillProcess(
|
||||
case *pb.KillProcessRequest_Tag:
|
||||
tag = sel.Tag
|
||||
default:
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("pid or tag is required"))
|
||||
return nil, apperr.ToConnect(apperr.InvalidRequest.Msg("A pid or tag is required."))
|
||||
}
|
||||
|
||||
// Map signal string to envd enum.
|
||||
@ -870,14 +899,11 @@ func (s *Server) KillProcess(
|
||||
case "SIGTERM":
|
||||
signal = envdpb.Signal_SIGNAL_SIGTERM
|
||||
default:
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unsupported signal: %s (use SIGKILL or SIGTERM)", msg.Signal))
|
||||
return nil, apperr.ToConnect(apperr.InvalidRequest.Msgf("Unsupported signal: %s (use SIGKILL or SIGTERM).", msg.Signal))
|
||||
}
|
||||
|
||||
if err := s.mgr.KillProcess(ctx, msg.SandboxId, pid, tag, signal); err != nil {
|
||||
if errors.Is(err, sandbox.ErrNotFound) {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("kill process: %w", err))
|
||||
return nil, mapSandboxError(fmt.Errorf("kill process: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.KillProcessResponse{}), nil
|
||||
@ -898,15 +924,12 @@ func (s *Server) ConnectProcess(
|
||||
case *pb.ConnectProcessRequest_Tag:
|
||||
tag = sel.Tag
|
||||
default:
|
||||
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("pid or tag is required"))
|
||||
return apperr.ToConnect(apperr.InvalidRequest.Msg("A pid or tag is required."))
|
||||
}
|
||||
|
||||
events, err := s.mgr.ConnectProcess(ctx, msg.SandboxId, pid, tag)
|
||||
if err != nil {
|
||||
if errors.Is(err, sandbox.ErrNotFound) {
|
||||
return connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("connect process: %w", err))
|
||||
return mapSandboxError(fmt.Errorf("connect process: %w", err))
|
||||
}
|
||||
|
||||
for ev := range events {
|
||||
|
||||
@ -276,7 +276,7 @@ func execUser(
|
||||
) (BuildLogEntry, bool) {
|
||||
username := st.Key
|
||||
// Create user if not exists, with home directory and bash shell.
|
||||
// Grant passwordless sudo access (E2B convention).
|
||||
// Grant passwordless sudo access (matches the wrenn-user default in base images).
|
||||
// Uses printf %s to avoid shell injection in the sudoers line.
|
||||
script := fmt.Sprintf(
|
||||
"id %s >/dev/null 2>&1 || (adduser --disabled-password --gecos '' --shell /bin/bash %s && printf '%%s ALL=(ALL) NOPASSWD:ALL\\n' %s >> /etc/sudoers)",
|
||||
|
||||
@ -1 +0,0 @@
|
||||
package snapshot
|
||||
@ -1 +0,0 @@
|
||||
package snapshot
|
||||
150
pkg/apperr/apperr.go
Normal file
150
pkg/apperr/apperr.go
Normal file
@ -0,0 +1,150 @@
|
||||
// Package apperr is Wrenn's application error domain — the single source of
|
||||
// truth for error codes, HTTP statuses, client-safe messages, and retryability.
|
||||
//
|
||||
// Every error that can reach a client is declared once as a Def in
|
||||
// catalog.go. Code that fails wraps its cause with a Def
|
||||
// (Def.Wrap, Def.Msg, ...), and the HTTP layer resolves any error to a
|
||||
// wire-safe *Error with From. The cause chain is preserved for logs but never
|
||||
// serialized to clients.
|
||||
package apperr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Error is a resolved application error. Code, Status, Message, Retryable and
|
||||
// Details are client-safe; the wrapped cause is for logs only.
|
||||
type Error struct {
|
||||
Code string
|
||||
Status int
|
||||
Message string
|
||||
Retryable bool
|
||||
Details map[string]any
|
||||
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.cause != nil {
|
||||
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.cause)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error { return e.cause }
|
||||
|
||||
// Is makes errors.Is(err, SomeDef.New()) and cross-instance comparisons match
|
||||
// on Code, so two Errors with the same code compare equal regardless of
|
||||
// message, details, or cause.
|
||||
func (e *Error) Is(target error) bool {
|
||||
var t *Error
|
||||
if errors.As(target, &t) {
|
||||
return t.Code == e.Code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// With returns a copy of the error with an extra client-visible detail.
|
||||
// Details must be safe to show to API consumers.
|
||||
func (e *Error) With(key string, value any) *Error {
|
||||
c := *e
|
||||
c.Details = maps.Clone(c.Details)
|
||||
if c.Details == nil {
|
||||
c.Details = map[string]any{}
|
||||
}
|
||||
c.Details[key] = value
|
||||
return &c
|
||||
}
|
||||
|
||||
// Def is a catalog entry: an error code with its fixed HTTP status,
|
||||
// default client-safe message, and retryability. Defs are declared in
|
||||
// catalog.go and instantiated at failure sites.
|
||||
type Def struct {
|
||||
Code string
|
||||
Status int
|
||||
Retryable bool
|
||||
Message string
|
||||
}
|
||||
|
||||
// New returns an Error with the Def's default message.
|
||||
func (d Def) New() *Error {
|
||||
return &Error{Code: d.Code, Status: d.Status, Message: d.Message, Retryable: d.Retryable}
|
||||
}
|
||||
|
||||
// Msg returns an Error with a custom client-safe message.
|
||||
func (d Def) Msg(message string) *Error {
|
||||
e := d.New()
|
||||
e.Message = message
|
||||
return e
|
||||
}
|
||||
|
||||
// Msgf returns an Error with a formatted client-safe message.
|
||||
// The arguments become part of the client response — never pass raw
|
||||
// internal error values; use Wrap for those.
|
||||
func (d Def) Msgf(format string, args ...any) *Error {
|
||||
return d.Msg(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Wrap returns an Error carrying cause for logs, with the Def's default
|
||||
// client-safe message. The cause is never serialized to clients.
|
||||
func (d Def) Wrap(cause error) *Error {
|
||||
e := d.New()
|
||||
e.cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// WrapMsg is Wrap with a custom client-safe message.
|
||||
func (d Def) WrapMsg(cause error, message string) *Error {
|
||||
e := d.Wrap(cause)
|
||||
e.Message = message
|
||||
return e
|
||||
}
|
||||
|
||||
// Is reports whether err resolves to this Def's code.
|
||||
func (d Def) Is(err error) bool {
|
||||
var e *Error
|
||||
return errors.As(err, &e) && e.Code == d.Code
|
||||
}
|
||||
|
||||
// From resolves any error to a client-safe *Error.
|
||||
//
|
||||
// - *Error anywhere in the chain: returned as-is.
|
||||
// - context deadline/cancellation: Timeout — matched on the cause chain
|
||||
// (errors.Is) before the Connect branch, so a transport error that wraps a
|
||||
// live context deadline still surfaces as a retryable timeout rather than
|
||||
// being flattened to internal_error.
|
||||
// - Connect RPC errors: resolved via the attached ErrorInfo detail when the
|
||||
// far side provided one, otherwise Internal (see fromConnect).
|
||||
// - anything else: Internal — the cause is preserved for logging, the
|
||||
// client sees only the generic message.
|
||||
func From(err error) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e
|
||||
}
|
||||
if isContextTimeout(err) {
|
||||
return Timeout.Wrap(err)
|
||||
}
|
||||
if ce := asConnectError(err); ce != nil {
|
||||
return fromConnect(ce)
|
||||
}
|
||||
return Internal.Wrap(err)
|
||||
}
|
||||
|
||||
// Cause returns the wrapped internal cause, or nil.
|
||||
func (e *Error) Cause() error { return e.cause }
|
||||
|
||||
// HTTPStatus returns the error's HTTP status, defaulting to 500 for
|
||||
// zero values so a misconstructed Error can never turn into a 200.
|
||||
func (e *Error) HTTPStatus() int {
|
||||
if e.Status == 0 {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
return e.Status
|
||||
}
|
||||
117
pkg/apperr/apperr_test.go
Normal file
117
pkg/apperr/apperr_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package apperr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefConstructors(t *testing.T) {
|
||||
d := Def{Code: "test_failed", Status: http.StatusTeapot, Retryable: true, Message: "default message"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err *Error
|
||||
wantMessage string
|
||||
wantCause bool
|
||||
}{
|
||||
{"New", d.New(), "default message", false},
|
||||
{"Msg", d.Msg("custom"), "custom", false},
|
||||
{"Msgf", d.Msgf("custom %d", 42), "custom 42", false},
|
||||
{"Wrap", d.Wrap(errors.New("boom")), "default message", true},
|
||||
{"WrapMsg", d.WrapMsg(errors.New("boom"), "custom"), "custom", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.err.Code != "test_failed" || tt.err.Status != http.StatusTeapot || !tt.err.Retryable {
|
||||
t.Errorf("def fields not carried: %+v", tt.err)
|
||||
}
|
||||
if tt.err.Message != tt.wantMessage {
|
||||
t.Errorf("Message = %q, want %q", tt.err.Message, tt.wantMessage)
|
||||
}
|
||||
if (tt.err.Cause() != nil) != tt.wantCause {
|
||||
t.Errorf("Cause() = %v, wantCause %v", tt.err.Cause(), tt.wantCause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsIsMatchesOnCode(t *testing.T) {
|
||||
d := Def{Code: "thing_missing", Status: 404, Message: "not found"}
|
||||
other := Def{Code: "other_code", Status: 404, Message: "not found"}
|
||||
|
||||
wrapped := fmt.Errorf("outer: %w", d.Msg("different message"))
|
||||
if !errors.Is(wrapped, d.New()) {
|
||||
t.Error("errors.Is should match same code through wrapping")
|
||||
}
|
||||
if errors.Is(wrapped, other.New()) {
|
||||
t.Error("errors.Is should not match different code")
|
||||
}
|
||||
if !d.Is(wrapped) {
|
||||
t.Error("Def.Is should match same code through wrapping")
|
||||
}
|
||||
if other.Is(wrapped) {
|
||||
t.Error("Def.Is should not match different code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDoesNotMutateOriginal(t *testing.T) {
|
||||
d := Def{Code: "x", Status: 400, Message: "m"}
|
||||
base := d.New().With("a", 1)
|
||||
derived := base.With("b", 2)
|
||||
|
||||
if len(base.Details) != 1 {
|
||||
t.Errorf("base details mutated: %v", base.Details)
|
||||
}
|
||||
if derived.Details["a"] != 1 || derived.Details["b"] != 2 {
|
||||
t.Errorf("derived details wrong: %v", derived.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrom(t *testing.T) {
|
||||
d := Def{Code: "known", Status: 404, Message: "known thing missing"}
|
||||
|
||||
t.Run("nil", func(t *testing.T) {
|
||||
if From(nil) != nil {
|
||||
t.Error("From(nil) should be nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("passthrough", func(t *testing.T) {
|
||||
e := d.Wrap(errors.New("cause"))
|
||||
got := From(fmt.Errorf("outer: %w", e))
|
||||
if got.Code != "known" {
|
||||
t.Errorf("Code = %q, want known", got.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("context deadline", func(t *testing.T) {
|
||||
got := From(fmt.Errorf("op: %w", context.DeadlineExceeded))
|
||||
if got.Code != Timeout.Code {
|
||||
t.Errorf("Code = %q, want %q", got.Code, Timeout.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown becomes internal", func(t *testing.T) {
|
||||
cause := errors.New("db exploded at /var/lib/secret")
|
||||
got := From(cause)
|
||||
if got.Code != Internal.Code {
|
||||
t.Errorf("Code = %q, want %q", got.Code, Internal.Code)
|
||||
}
|
||||
if got.Message != Internal.Message {
|
||||
t.Errorf("internal error must not leak cause; Message = %q", got.Message)
|
||||
}
|
||||
if !errors.Is(got, cause) {
|
||||
t.Error("cause must be preserved in chain for logging")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPStatusDefaultsTo500(t *testing.T) {
|
||||
e := &Error{Code: "misconstructed"}
|
||||
if e.HTTPStatus() != http.StatusInternalServerError {
|
||||
t.Errorf("HTTPStatus = %d, want 500", e.HTTPStatus())
|
||||
}
|
||||
}
|
||||
229
pkg/apperr/catalog.go
Normal file
229
pkg/apperr/catalog.go
Normal file
@ -0,0 +1,229 @@
|
||||
package apperr
|
||||
|
||||
import "net/http"
|
||||
|
||||
// registry maps code → Def so errors received over RPC (see connect.go) and
|
||||
// codes from extensions resolve back to their catalog entry.
|
||||
var registry = map[string]Def{}
|
||||
|
||||
func register(d Def) Def {
|
||||
registry[d.Code] = d
|
||||
return d
|
||||
}
|
||||
|
||||
// Generic codes. Prefer a domain-specific Def when the client can act on the
|
||||
// distinction; use these with Msg for one-off cases.
|
||||
var (
|
||||
Internal = register(Def{
|
||||
Code: "internal_error",
|
||||
Status: http.StatusInternalServerError,
|
||||
Message: "Something went wrong on our end. Try again; if it keeps failing, contact support with the request ID.",
|
||||
})
|
||||
InvalidRequest = register(Def{
|
||||
Code: "invalid_request",
|
||||
Status: http.StatusBadRequest,
|
||||
Message: "The request is invalid.",
|
||||
})
|
||||
ValidationFailed = register(Def{
|
||||
Code: "validation_failed",
|
||||
Status: http.StatusBadRequest,
|
||||
Message: "One or more fields are invalid.",
|
||||
})
|
||||
Unauthorized = register(Def{
|
||||
Code: "unauthorized",
|
||||
Status: http.StatusUnauthorized,
|
||||
Message: "Authentication required.",
|
||||
})
|
||||
Forbidden = register(Def{
|
||||
Code: "forbidden",
|
||||
Status: http.StatusForbidden,
|
||||
Message: "You don't have permission to do that.",
|
||||
})
|
||||
NotFound = register(Def{
|
||||
Code: "not_found",
|
||||
Status: http.StatusNotFound,
|
||||
Message: "The requested resource was not found.",
|
||||
})
|
||||
Conflict = register(Def{
|
||||
Code: "conflict",
|
||||
Status: http.StatusConflict,
|
||||
Message: "The request conflicts with the current state of the resource.",
|
||||
})
|
||||
PayloadTooLarge = register(Def{
|
||||
Code: "payload_too_large",
|
||||
Status: http.StatusRequestEntityTooLarge,
|
||||
Message: "The request payload is too large.",
|
||||
})
|
||||
RateLimited = register(Def{
|
||||
Code: "rate_limited",
|
||||
Status: http.StatusTooManyRequests,
|
||||
Retryable: true,
|
||||
Message: "Too many requests. Slow down and try again.",
|
||||
})
|
||||
Timeout = register(Def{
|
||||
Code: "timeout",
|
||||
Status: http.StatusGatewayTimeout,
|
||||
Retryable: true,
|
||||
Message: "The operation timed out. Try again.",
|
||||
})
|
||||
Unavailable = register(Def{
|
||||
Code: "service_unavailable",
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Retryable: true,
|
||||
Message: "The service is temporarily unavailable. Try again shortly.",
|
||||
})
|
||||
NotImplemented = register(Def{
|
||||
Code: "not_implemented",
|
||||
Status: http.StatusNotImplemented,
|
||||
Message: "This operation is not supported.",
|
||||
})
|
||||
)
|
||||
|
||||
// Auth and account codes.
|
||||
var (
|
||||
AuthInvalidCredentials = register(Def{
|
||||
Code: "auth_invalid_credentials",
|
||||
Status: http.StatusUnauthorized,
|
||||
Message: "Invalid email or password.",
|
||||
})
|
||||
AuthSessionRequired = register(Def{
|
||||
Code: "auth_session_required",
|
||||
Status: http.StatusUnauthorized,
|
||||
Message: "A valid session or API key is required.",
|
||||
})
|
||||
AuthInvalidAPIKey = register(Def{
|
||||
Code: "auth_invalid_api_key",
|
||||
Status: http.StatusUnauthorized,
|
||||
Message: "Invalid API key.",
|
||||
})
|
||||
AuthCSRF = register(Def{
|
||||
Code: "auth_csrf_failed",
|
||||
Status: http.StatusForbidden,
|
||||
Message: "CSRF token missing or invalid. Refresh the page and try again.",
|
||||
})
|
||||
AuthAccountNotActivated = register(Def{
|
||||
Code: "auth_account_not_activated",
|
||||
Status: http.StatusForbidden,
|
||||
Message: "Check your email and activate your account before signing in.",
|
||||
})
|
||||
AuthAccountDisabled = register(Def{
|
||||
Code: "auth_account_disabled",
|
||||
Status: http.StatusForbidden,
|
||||
Message: "Your account has been deactivated — contact your administrator to regain access.",
|
||||
})
|
||||
AuthEmailTaken = register(Def{
|
||||
Code: "auth_email_taken",
|
||||
Status: http.StatusConflict,
|
||||
Message: "An account with this email already exists.",
|
||||
})
|
||||
AuthSignupCooldown = register(Def{
|
||||
Code: "auth_signup_cooldown",
|
||||
Status: http.StatusConflict,
|
||||
Message: "A signup for this email is already pending. Check your inbox or try again later.",
|
||||
})
|
||||
AuthTokenInvalid = register(Def{
|
||||
Code: "auth_token_invalid",
|
||||
Status: http.StatusBadRequest,
|
||||
Message: "This link is invalid or has expired.",
|
||||
})
|
||||
AuthAlreadyActivated = register(Def{
|
||||
Code: "auth_already_activated",
|
||||
Status: http.StatusConflict,
|
||||
Message: "This account has already been activated.",
|
||||
})
|
||||
)
|
||||
|
||||
// Sandbox lifecycle and infrastructure codes.
|
||||
var (
|
||||
SandboxNotFound = register(Def{
|
||||
Code: "sandbox_not_found",
|
||||
Status: http.StatusNotFound,
|
||||
Message: "Sandbox not found.",
|
||||
})
|
||||
SandboxNotRunning = register(Def{
|
||||
Code: "sandbox_not_running",
|
||||
Status: http.StatusConflict,
|
||||
Message: "Sandbox is not running.",
|
||||
})
|
||||
SandboxNotPaused = register(Def{
|
||||
Code: "sandbox_not_paused",
|
||||
Status: http.StatusConflict,
|
||||
Message: "Sandbox is not paused.",
|
||||
})
|
||||
SandboxUnresponsive = register(Def{
|
||||
Code: "sandbox_unresponsive",
|
||||
Status: http.StatusBadGateway,
|
||||
Retryable: true,
|
||||
Message: "The sandbox is not responding. Try again shortly.",
|
||||
})
|
||||
HostUnreachable = register(Def{
|
||||
Code: "host_unreachable",
|
||||
Status: http.StatusBadGateway,
|
||||
Retryable: true,
|
||||
Message: "Could not reach the sandbox's host. Try again shortly.",
|
||||
})
|
||||
HostDraining = register(Def{
|
||||
Code: "host_draining",
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Retryable: true,
|
||||
Message: "The host is shutting down and not accepting new work. Try again shortly.",
|
||||
})
|
||||
CapacityUnavailable = register(Def{
|
||||
Code: "capacity_unavailable",
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Retryable: true,
|
||||
Message: "No hosts currently have capacity for this request. Try again shortly.",
|
||||
})
|
||||
TemplateNotFound = register(Def{
|
||||
Code: "template_not_found",
|
||||
Status: http.StatusNotFound,
|
||||
Message: "Template not found.",
|
||||
})
|
||||
TemplateProtected = register(Def{
|
||||
Code: "template_protected",
|
||||
Status: http.StatusForbidden,
|
||||
Message: "System templates cannot be modified or deleted.",
|
||||
})
|
||||
SnapshotNotFound = register(Def{
|
||||
Code: "snapshot_not_found",
|
||||
Status: http.StatusNotFound,
|
||||
Message: "Snapshot not found.",
|
||||
})
|
||||
BuildNotFound = register(Def{
|
||||
Code: "build_not_found",
|
||||
Status: http.StatusNotFound,
|
||||
Message: "Build not found.",
|
||||
})
|
||||
HostNotFound = register(Def{
|
||||
Code: "host_not_found",
|
||||
Status: http.StatusNotFound,
|
||||
Message: "Host not found.",
|
||||
})
|
||||
HostHasActiveSandboxes = register(Def{
|
||||
Code: "has_active_sandboxes",
|
||||
Status: http.StatusConflict,
|
||||
Message: "This host has active sandboxes. Pass ?force=true to destroy them and delete the host.",
|
||||
})
|
||||
TeamNotFound = register(Def{
|
||||
Code: "team_not_found",
|
||||
Status: http.StatusNotFound,
|
||||
Message: "Team not found.",
|
||||
})
|
||||
UserNotFound = register(Def{
|
||||
Code: "user_not_found",
|
||||
Status: http.StatusNotFound,
|
||||
Message: "User not found.",
|
||||
})
|
||||
)
|
||||
|
||||
// Lookup returns the catalog Def for a code, or false if unregistered.
|
||||
// Extensions can add their own codes with Register.
|
||||
func Lookup(code string) (Def, bool) {
|
||||
d, ok := registry[code]
|
||||
return d, ok
|
||||
}
|
||||
|
||||
// Register adds a Def to the catalog registry so errors carrying its code
|
||||
// resolve to it when received over RPC. Intended for extensions; OSS codes
|
||||
// are registered at package init. Registering an existing code overwrites it.
|
||||
func Register(d Def) Def { return register(d) }
|
||||
119
pkg/apperr/connect.go
Normal file
119
pkg/apperr/connect.go
Normal file
@ -0,0 +1,119 @@
|
||||
package apperr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
|
||||
)
|
||||
|
||||
// ToConnect converts any error to a *connect.Error carrying an ErrorInfo
|
||||
// detail. The RPC error message keeps the full cause chain (the CP↔agent
|
||||
// channel is internal, and the receiver logs it); the ErrorInfo detail holds
|
||||
// only the client-safe fields, which the receiving side re-surfaces via From.
|
||||
func ToConnect(err error) *connect.Error {
|
||||
e := From(err)
|
||||
if e == nil {
|
||||
// Defensive: a nil error reaching here is a caller bug. Return a
|
||||
// generic internal error rather than panicking on the nil deref.
|
||||
e = Internal.New()
|
||||
}
|
||||
ce := connect.NewError(connectCodeFor(e.HTTPStatus()), errors.New(e.Error()))
|
||||
if detail, derr := connect.NewErrorDetail(errorInfo(e)); derr == nil {
|
||||
ce.AddDetail(detail)
|
||||
}
|
||||
return ce
|
||||
}
|
||||
|
||||
func errorInfo(e *Error) *pb.ErrorInfo {
|
||||
info := &pb.ErrorInfo{
|
||||
Code: e.Code,
|
||||
Message: e.Message,
|
||||
Retryable: e.Retryable,
|
||||
HttpStatus: int32(e.HTTPStatus()),
|
||||
}
|
||||
if len(e.Details) > 0 {
|
||||
info.Details = make(map[string]string, len(e.Details))
|
||||
for k, v := range e.Details {
|
||||
info.Details[k] = fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func asConnectError(err error) *connect.Error {
|
||||
var ce *connect.Error
|
||||
if errors.As(err, &ce) {
|
||||
return ce
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fromConnect resolves a Connect RPC error to an *Error. Every wrenn service
|
||||
// attaches an ErrorInfo detail via ToConnect, so an error carrying one is
|
||||
// reconstructed losslessly. An error WITHOUT a detail did not originate from a
|
||||
// wrenn handler — it is a transport-level failure synthesized by the Connect
|
||||
// framework itself (a dropped connection, a client-side deadline) — so it
|
||||
// resolves to internal_error, with the raw RPC error hidden from the client
|
||||
// but kept in the chain for logs.
|
||||
func fromConnect(ce *connect.Error) *Error {
|
||||
for _, d := range ce.Details() {
|
||||
msg, err := d.Value()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info, ok := msg.(*pb.ErrorInfo); ok {
|
||||
e := &Error{
|
||||
Code: info.Code,
|
||||
Status: int(info.HttpStatus),
|
||||
Message: info.Message,
|
||||
Retryable: info.Retryable,
|
||||
cause: ce,
|
||||
}
|
||||
if e.Status == 0 {
|
||||
if d, ok := Lookup(info.Code); ok {
|
||||
e.Status = d.Status
|
||||
} else {
|
||||
e.Status = http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
if len(info.Details) > 0 {
|
||||
e.Details = make(map[string]any, len(info.Details))
|
||||
for k, v := range info.Details {
|
||||
e.Details[k] = v
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
}
|
||||
return Internal.Wrap(ce)
|
||||
}
|
||||
|
||||
// connectCodeFor maps an HTTP status to the Connect code used on the wire.
|
||||
func connectCodeFor(status int) connect.Code {
|
||||
switch status {
|
||||
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
|
||||
return connect.CodeInvalidArgument
|
||||
case http.StatusUnauthorized:
|
||||
return connect.CodeUnauthenticated
|
||||
case http.StatusForbidden:
|
||||
return connect.CodePermissionDenied
|
||||
case http.StatusNotFound:
|
||||
return connect.CodeNotFound
|
||||
case http.StatusConflict:
|
||||
return connect.CodeFailedPrecondition
|
||||
case http.StatusTooManyRequests:
|
||||
return connect.CodeResourceExhausted
|
||||
case http.StatusNotImplemented:
|
||||
return connect.CodeUnimplemented
|
||||
case http.StatusBadGateway, http.StatusServiceUnavailable:
|
||||
return connect.CodeUnavailable
|
||||
case http.StatusGatewayTimeout:
|
||||
return connect.CodeDeadlineExceeded
|
||||
default:
|
||||
return connect.CodeInternal
|
||||
}
|
||||
}
|
||||
166
pkg/apperr/connect_test.go
Normal file
166
pkg/apperr/connect_test.go
Normal file
@ -0,0 +1,166 @@
|
||||
package apperr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
)
|
||||
|
||||
// WriteHTTP(nil) must not panic on the From(nil)==nil deref; it should emit a
|
||||
// generic 500 envelope rather than a bare 200 or a crash.
|
||||
func TestWriteHTTPNil(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
WriteHTTP(rec, req, nil)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
var body wireEnvelope
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body.Error.Code != Internal.Code {
|
||||
t.Errorf("code = %q, want %q", body.Error.Code, Internal.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectRoundTrip(t *testing.T) {
|
||||
orig := SandboxNotRunning.
|
||||
Wrap(errors.New("boxes map miss")).
|
||||
With("status", "paused")
|
||||
|
||||
ce := ToConnect(orig)
|
||||
if ce.Code() != connect.CodeFailedPrecondition {
|
||||
t.Errorf("connect code = %v, want FailedPrecondition", ce.Code())
|
||||
}
|
||||
|
||||
got := From(fmt.Errorf("rpc CreateSandbox: %w", ce))
|
||||
if got.Code != orig.Code {
|
||||
t.Errorf("Code = %q, want %q", got.Code, orig.Code)
|
||||
}
|
||||
if got.Status != orig.Status {
|
||||
t.Errorf("Status = %d, want %d", got.Status, orig.Status)
|
||||
}
|
||||
if got.Message != orig.Message {
|
||||
t.Errorf("Message = %q, want %q", got.Message, orig.Message)
|
||||
}
|
||||
if got.Retryable != orig.Retryable {
|
||||
t.Errorf("Retryable = %v, want %v", got.Retryable, orig.Retryable)
|
||||
}
|
||||
if got.Details["status"] != "paused" {
|
||||
t.Errorf("Details = %v, want status=paused", got.Details)
|
||||
}
|
||||
// The internal cause must not surface in client-safe fields.
|
||||
if got.Message == orig.Error() {
|
||||
t.Error("cause chain leaked into Message")
|
||||
}
|
||||
}
|
||||
|
||||
// Bad-gateway (502) catalog defs must map to a retryable Connect code, not the
|
||||
// generic Internal, so observability and consumers keying off the raw wire code
|
||||
// see a transient infrastructure failure rather than an internal-invariant bug.
|
||||
func TestToConnectBadGateway(t *testing.T) {
|
||||
for _, def := range []Def{SandboxUnresponsive, HostUnreachable} {
|
||||
t.Run(def.Code, func(t *testing.T) {
|
||||
ce := ToConnect(def.Wrap(errors.New("envd dial: connection refused")))
|
||||
if ce.Code() != connect.CodeUnavailable {
|
||||
t.Errorf("connect code = %v, want Unavailable", ce.Code())
|
||||
}
|
||||
// The ErrorInfo detail keeps the round trip lossless.
|
||||
got := From(fmt.Errorf("rpc: %w", ce))
|
||||
if got.Code != def.Code {
|
||||
t.Errorf("Code = %q, want %q", got.Code, def.Code)
|
||||
}
|
||||
if got.Status != http.StatusBadGateway {
|
||||
t.Errorf("Status = %d, want 502", got.Status)
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Error("Retryable = false, want true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Without an ErrorInfo detail, a Connect error did not come from a wrenn
|
||||
// handler — it is a transport-level failure synthesized by the framework — so
|
||||
// it resolves to internal_error regardless of its Connect code. The raw cause
|
||||
// is hidden from the client but stays in the chain for logs. (The old
|
||||
// guess-from-code mapping is gone.)
|
||||
func TestFromConnectWithoutDetail(t *testing.T) {
|
||||
codes := []connect.Code{
|
||||
connect.CodeUnavailable,
|
||||
connect.CodeNotFound,
|
||||
connect.CodeInvalidArgument,
|
||||
connect.CodeFailedPrecondition,
|
||||
connect.CodePermissionDenied,
|
||||
connect.CodeUnauthenticated,
|
||||
connect.CodeResourceExhausted,
|
||||
connect.CodeUnimplemented,
|
||||
connect.CodeDeadlineExceeded,
|
||||
connect.CodeInternal,
|
||||
connect.CodeUnknown,
|
||||
}
|
||||
for _, code := range codes {
|
||||
t.Run(code.String(), func(t *testing.T) {
|
||||
ce := connect.NewError(code, errors.New("open /var/lib/wrenn/secret: boom"))
|
||||
got := From(ce)
|
||||
if got.Code != Internal.Code {
|
||||
t.Errorf("Code = %q, want %q", got.Code, Internal.Code)
|
||||
}
|
||||
if got.Message != Internal.Message {
|
||||
t.Errorf("raw cause leaked to client message: %q", got.Message)
|
||||
}
|
||||
if !errors.Is(got, error(ce)) {
|
||||
t.Error("connect error must remain in chain for logging")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A Connect error that genuinely wraps a context deadline still resolves to
|
||||
// Timeout — matched on the cause chain, not guessed from the Connect code —
|
||||
// so a real client-side timeout stays a retryable 504.
|
||||
func TestFromContextDeadlineViaConnect(t *testing.T) {
|
||||
ce := connect.NewError(connect.CodeDeadlineExceeded, context.DeadlineExceeded)
|
||||
got := From(ce)
|
||||
if got.Code != Timeout.Code {
|
||||
t.Errorf("Code = %q, want %q", got.Code, Timeout.Code)
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Error("Retryable = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// ToConnect(nil) must not panic on the From(nil)==nil deref; a nil error
|
||||
// reaching here is a caller bug that should degrade to a generic internal
|
||||
// error, not crash the RPC handler.
|
||||
func TestToConnectNil(t *testing.T) {
|
||||
ce := ToConnect(nil)
|
||||
if ce.Code() != connect.CodeInternal {
|
||||
t.Errorf("code = %v, want Internal", ce.Code())
|
||||
}
|
||||
if got := From(ce); got.Code != Internal.Code {
|
||||
t.Errorf("Code = %q, want %q", got.Code, Internal.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToConnectFromPlainError(t *testing.T) {
|
||||
ce := ToConnect(errors.New("dmsetup create failed: device busy"))
|
||||
if ce.Code() != connect.CodeInternal {
|
||||
t.Errorf("code = %v, want Internal", ce.Code())
|
||||
}
|
||||
got := From(ce)
|
||||
if got.Code != Internal.Code || got.Status != http.StatusInternalServerError {
|
||||
t.Errorf("got %q/%d, want internal_error/500", got.Code, got.Status)
|
||||
}
|
||||
if got.Message != Internal.Message {
|
||||
t.Errorf("internal cause leaked to client message: %q", got.Message)
|
||||
}
|
||||
}
|
||||
14
pkg/apperr/context.go
Normal file
14
pkg/apperr/context.go
Normal file
@ -0,0 +1,14 @@
|
||||
package apperr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// isContextTimeout reports whether err is a context deadline or cancellation.
|
||||
// Both resolve to Timeout: a cancelled request context reaches error mapping
|
||||
// only when the work was cut short, which the client should treat the same
|
||||
// as a timeout.
|
||||
func isContextTimeout(err error) bool {
|
||||
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
|
||||
}
|
||||
98
pkg/apperr/http.go
Normal file
98
pkg/apperr/http.go
Normal file
@ -0,0 +1,98 @@
|
||||
package apperr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// RequestIDHeader is the response header carrying the request ID.
|
||||
const RequestIDHeader = "X-Request-ID"
|
||||
|
||||
// Middleware assigns each request an ID (req_ + 16 hex), stores it in the
|
||||
// context, and echoes it in the X-Request-ID response header. Error responses
|
||||
// include it so users can quote an ID that log lines are keyed by.
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := newRequestID()
|
||||
w.Header().Set(RequestIDHeader, id)
|
||||
next.ServeHTTP(w, r.WithContext(WithRequestID(r.Context(), id)))
|
||||
})
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return "req_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// WithRequestID returns a context carrying the request ID.
|
||||
func WithRequestID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, id)
|
||||
}
|
||||
|
||||
// RequestID returns the request ID from the context, or "" if none.
|
||||
func RequestID(ctx context.Context) string {
|
||||
id, _ := ctx.Value(ctxKey{}).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
type wireError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type wireEnvelope struct {
|
||||
Error wireError `json:"error"`
|
||||
}
|
||||
|
||||
// WriteHTTP resolves err via From and writes the error envelope. The full
|
||||
// cause chain is logged keyed by request ID — 5xx at ERROR, wrapped 4xx at
|
||||
// WARN — and never serialized to the client.
|
||||
func WriteHTTP(w http.ResponseWriter, r *http.Request, err error) {
|
||||
e := From(err)
|
||||
if e == nil {
|
||||
// Defensive: a nil error reaching here is a caller bug. Never emit a
|
||||
// bare 200 — surface a generic 500 so the client still gets an envelope.
|
||||
e = Internal.New()
|
||||
}
|
||||
reqID := RequestID(r.Context())
|
||||
|
||||
if e.HTTPStatus() >= 500 {
|
||||
slog.Error("request failed",
|
||||
"request_id", reqID,
|
||||
"code", e.Code,
|
||||
"status", e.HTTPStatus(),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"error", e.Error(),
|
||||
)
|
||||
} else if e.Cause() != nil {
|
||||
slog.Warn("request rejected",
|
||||
"request_id", reqID,
|
||||
"code", e.Code,
|
||||
"status", e.HTTPStatus(),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"error", e.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(e.HTTPStatus())
|
||||
_ = json.NewEncoder(w).Encode(wireEnvelope{Error: wireError{
|
||||
Code: e.Code,
|
||||
Message: e.Message,
|
||||
RequestID: reqID,
|
||||
Retryable: e.Retryable,
|
||||
Details: e.Details,
|
||||
}})
|
||||
}
|
||||
@ -308,15 +308,18 @@ func (l *AuditLogger) LogSandboxDestroyWithReason(ctx context.Context, ac auth.A
|
||||
})
|
||||
}
|
||||
|
||||
// LogSandboxCreateSystem records a system-derived create outcome (e.g. the
|
||||
// LogSandboxCreateSystem records a system-derived create failure (e.g. the
|
||||
// reconciler inferring a failed first-boot after the grace period expired).
|
||||
// reason is added to metadata; err controls outcome.
|
||||
// It writes an "error" action — not a "create" one — so the entry reads as a
|
||||
// capsule error rather than a system-attributed creation; the user-attributed
|
||||
// create row is written separately by the request handler. reason and the
|
||||
// "create" phase are added to metadata; err controls outcome.
|
||||
func (l *AuditLogger) LogSandboxCreateSystem(ctx context.Context, teamID, sandboxID pgtype.UUID, reason string, err error) {
|
||||
meta := map[string]any{"reason": reason}
|
||||
meta := map[string]any{"reason": reason, "phase": "create"}
|
||||
l.Log(ctx, Entry{
|
||||
TeamID: teamID, ActorType: "system",
|
||||
ResourceType: "sandbox", ResourceID: id.FormatSandboxID(sandboxID),
|
||||
Action: "create", Scope: "team", Status: auditStatusFor(err, "info"),
|
||||
Action: "error", Scope: "team", Status: auditStatusFor(err, "info"),
|
||||
Metadata: mergeMeta(meta, err),
|
||||
})
|
||||
l.publish(ctx, events.Event{
|
||||
@ -331,14 +334,16 @@ func (l *AuditLogger) LogSandboxCreateSystem(ctx context.Context, teamID, sandbo
|
||||
})
|
||||
}
|
||||
|
||||
// LogSandboxResumeSystem records a system-derived resume outcome (typically
|
||||
// reconciler-inferred error after the grace period).
|
||||
// LogSandboxResumeSystem records a system-derived resume failure (typically
|
||||
// reconciler-inferred error after the grace period). Like LogSandboxCreateSystem
|
||||
// it writes an "error" action so the entry reads as a capsule error rather than
|
||||
// a system-attributed resume; reason and the "resume" phase go to metadata.
|
||||
func (l *AuditLogger) LogSandboxResumeSystem(ctx context.Context, teamID, sandboxID pgtype.UUID, reason string, err error) {
|
||||
meta := map[string]any{"reason": reason}
|
||||
meta := map[string]any{"reason": reason, "phase": "resume"}
|
||||
l.Log(ctx, Entry{
|
||||
TeamID: teamID, ActorType: "system",
|
||||
ResourceType: "sandbox", ResourceID: id.FormatSandboxID(sandboxID),
|
||||
Action: "resume", Scope: "team", Status: auditStatusFor(err, "info"),
|
||||
Action: "error", Scope: "team", Status: auditStatusFor(err, "info"),
|
||||
Metadata: mergeMeta(meta, err),
|
||||
})
|
||||
l.publish(ctx, events.Event{
|
||||
|
||||
@ -7,19 +7,19 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/netutil"
|
||||
)
|
||||
|
||||
// Cookie + header names. Exported so extensions and frontends can reference
|
||||
@ -30,21 +30,6 @@ const (
|
||||
CSRFHeaderName = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
type errorBody struct {
|
||||
Error errorDetail `json:"error"`
|
||||
}
|
||||
|
||||
type errorDetail struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorBody{Error: errorDetail{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
// IsSecure reports whether the inbound request should produce Secure cookies.
|
||||
// Honors X-Forwarded-Proto for deployments behind TLS-terminating proxies.
|
||||
func IsSecure(r *http.Request) bool {
|
||||
@ -174,7 +159,7 @@ func RequireSession(svc *session.Service, queries *db.Queries) func(http.Handler
|
||||
sess, err := ResolveSession(r.Context(), queries, svc, r)
|
||||
if err != nil {
|
||||
ClearCookies(w, IsSecure(r))
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "valid session required")
|
||||
apperr.WriteHTTP(w, r, apperr.AuthSessionRequired.WrapMsg(err, "A valid session is required."))
|
||||
return
|
||||
}
|
||||
ctx := auth.WithAuthContext(r.Context(), AuthContextFromSession(sess))
|
||||
@ -192,13 +177,13 @@ func RequireSessionOrAPIKey(svc *session.Service, queries *db.Queries) func(http
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key")
|
||||
apperr.WriteHTTP(w, r, apperr.AuthInvalidAPIKey.New())
|
||||
return
|
||||
}
|
||||
sess, err := ResolveSession(r.Context(), queries, svc, r)
|
||||
if err != nil {
|
||||
ClearCookies(w, IsSecure(r))
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "X-API-Key header or session cookie required")
|
||||
apperr.WriteHTTP(w, r, apperr.AuthSessionRequired.Wrap(err))
|
||||
return
|
||||
}
|
||||
ctx := auth.WithAuthContext(r.Context(), AuthContextFromSession(sess))
|
||||
@ -216,12 +201,12 @@ func RequireAdmin(queries *db.Queries) func(http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ac, ok := auth.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
apperr.WriteHTTP(w, r, apperr.Unauthorized.New())
|
||||
return
|
||||
}
|
||||
user, err := queries.GetUserByID(r.Context(), ac.UserID)
|
||||
if err != nil || !user.IsAdmin {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin access required")
|
||||
apperr.WriteHTTP(w, r, apperr.Forbidden.WrapMsg(err, "Admin access required."))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@ -248,7 +233,7 @@ func RequireCSRF() func(http.Handler) http.Handler {
|
||||
header := r.Header.Get(CSRFHeaderName)
|
||||
if err != nil || cookie.Value == "" || header == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(header)) != 1 {
|
||||
writeError(w, http.StatusForbidden, "csrf_failed", "missing or invalid CSRF token")
|
||||
apperr.WriteHTTP(w, r, apperr.AuthCSRF.New())
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@ -279,7 +264,7 @@ func IssueSession(
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
sess, err := svc.Create(ctx, userID, teamID, user.Email, user.Name, role, user.IsAdmin, r.UserAgent(), clientIP(r))
|
||||
sess, err := svc.Create(ctx, userID, teamID, user.Email, user.Name, role, user.IsAdmin, r.UserAgent(), netutil.ClientIP(r))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -287,12 +272,3 @@ func IssueSession(
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
if i := strings.IndexByte(fwd, ','); i > 0 {
|
||||
return strings.TrimSpace(fwd[:i])
|
||||
}
|
||||
return strings.TrimSpace(fwd)
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
@ -12,14 +12,16 @@ import (
|
||||
|
||||
// Deliver sends a notification to a single provider with the given config.
|
||||
// For webhooks it uses HMAC-signed HTTP POST; for all others it uses shoutrrr.
|
||||
func Deliver(ctx context.Context, provider string, config map[string]string, e events.Event) error {
|
||||
// allowPrivate relaxes the SSRF guard on the webhook client for self-hosted
|
||||
// deployments that deliver to internal endpoints.
|
||||
func Deliver(ctx context.Context, provider string, config map[string]string, e events.Event, allowPrivate bool) error {
|
||||
payload, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event: %w", err)
|
||||
}
|
||||
|
||||
if provider == "webhook" {
|
||||
wh := NewWebhookDelivery()
|
||||
wh := NewWebhookDelivery(allowPrivate)
|
||||
return wh.Deliver(ctx, config["url"], config["secret"], payload)
|
||||
}
|
||||
|
||||
|
||||
@ -18,22 +18,31 @@ const (
|
||||
consumerName = "cp-0"
|
||||
)
|
||||
|
||||
// maxConcurrentRetries bounds the number of in-flight delivery retries. Without
|
||||
// it, an endpoint outage during an event burst would spawn one goroutine per
|
||||
// failed delivery — each holding decrypted secrets in memory for up to the full
|
||||
// retry window.
|
||||
const maxConcurrentRetries = 64
|
||||
|
||||
// Dispatcher consumes events from the Redis stream and delivers them
|
||||
// to matching notification channels.
|
||||
type Dispatcher struct {
|
||||
rdb *redis.Client
|
||||
db *db.Queries
|
||||
encKey [32]byte
|
||||
webhook *WebhookDelivery
|
||||
rdb *redis.Client
|
||||
db *db.Queries
|
||||
encKey [32]byte
|
||||
allowPrivate bool
|
||||
retrySem chan struct{}
|
||||
}
|
||||
|
||||
// NewDispatcher constructs an event dispatcher.
|
||||
func NewDispatcher(rdb *redis.Client, queries *db.Queries, encKey [32]byte) *Dispatcher {
|
||||
// NewDispatcher constructs an event dispatcher. allowPrivate relaxes the SSRF
|
||||
// guard on outbound webhook deliveries.
|
||||
func NewDispatcher(rdb *redis.Client, queries *db.Queries, encKey [32]byte, allowPrivate bool) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
rdb: rdb,
|
||||
db: queries,
|
||||
encKey: encKey,
|
||||
webhook: NewWebhookDelivery(),
|
||||
rdb: rdb,
|
||||
db: queries,
|
||||
encKey: encKey,
|
||||
allowPrivate: allowPrivate,
|
||||
retrySem: make(chan struct{}, maxConcurrentRetries),
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,10 +147,21 @@ func (d *Dispatcher) dispatch(ctx context.Context, ch db.Channel, e events.Event
|
||||
|
||||
chID := id.FormatChannelID(ch.ID)
|
||||
|
||||
if err := Deliver(ctx, ch.Provider, config, e); err != nil {
|
||||
if err := Deliver(ctx, ch.Provider, config, e, d.allowPrivate); err != nil {
|
||||
slog.Warn("channels: delivery failed, scheduling retries",
|
||||
"channel_id", chID, "provider", ch.Provider, "error", err)
|
||||
go d.retryDeliver(ctx, ch.Provider, config, e, chID)
|
||||
// Bound concurrent retries: acquire a slot or drop (with a log) rather
|
||||
// than spawning unbounded goroutines during a sustained outage.
|
||||
select {
|
||||
case d.retrySem <- struct{}{}:
|
||||
go func() {
|
||||
defer func() { <-d.retrySem }()
|
||||
d.retryDeliver(ctx, ch.Provider, config, e, chID)
|
||||
}()
|
||||
default:
|
||||
slog.Warn("channels: retry queue saturated, dropping retry",
|
||||
"channel_id", chID, "provider", ch.Provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,7 +173,7 @@ func (d *Dispatcher) retryDeliver(ctx context.Context, provider string, config m
|
||||
case <-time.After(delay):
|
||||
}
|
||||
|
||||
if err := Deliver(ctx, provider, config, e); err != nil {
|
||||
if err := Deliver(ctx, provider, config, e, d.allowPrivate); err != nil {
|
||||
slog.Warn("channels: retry delivery failed",
|
||||
"channel_id", chID, "provider", provider,
|
||||
"attempt", i+2, "error", err)
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/events"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
@ -55,6 +56,8 @@ func init() {
|
||||
type Service struct {
|
||||
DB *db.Queries
|
||||
EncKey [32]byte
|
||||
// AllowPrivateTargets disables the SSRF guard on channel destination URLs.
|
||||
AllowPrivateTargets bool
|
||||
}
|
||||
|
||||
// CreateParams holds the parameters for creating a channel.
|
||||
@ -81,25 +84,30 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (CreateResult, err
|
||||
p.Name = clean
|
||||
|
||||
if !validProviders[p.Provider] {
|
||||
return CreateResult{}, fmt.Errorf("invalid: unsupported provider %q", p.Provider)
|
||||
return CreateResult{}, apperr.ValidationFailed.Msgf("Unsupported provider %q.", p.Provider).With("field", "provider")
|
||||
}
|
||||
|
||||
if len(p.Events) == 0 {
|
||||
return CreateResult{}, fmt.Errorf("invalid: at least one event type is required")
|
||||
return CreateResult{}, apperr.ValidationFailed.Msg("At least one event type is required.").With("field", "events")
|
||||
}
|
||||
for _, et := range p.Events {
|
||||
if !validEvents[et] {
|
||||
return CreateResult{}, fmt.Errorf("invalid: unknown event type %q", et)
|
||||
return CreateResult{}, apperr.ValidationFailed.Msgf("Unknown event type %q.", et).With("field", "events")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required config fields.
|
||||
for _, field := range requiredFields[p.Provider] {
|
||||
if p.Config[field] == "" {
|
||||
return CreateResult{}, fmt.Errorf("invalid: %s is required for %s", field, p.Provider)
|
||||
return CreateResult{}, apperr.ValidationFailed.Msgf("The %s field is required for %s.", field, p.Provider).With("field", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Reject destination URLs pointing at internal/private addresses (SSRF).
|
||||
if err := validateConfigURLs(p.Config, s.AllowPrivateTargets); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
|
||||
// For webhooks, auto-generate secret if not provided.
|
||||
var plaintextSecret string
|
||||
if p.Provider == "webhook" {
|
||||
@ -138,7 +146,7 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (CreateResult, err
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return CreateResult{}, fmt.Errorf("conflict: channel name %q already exists", p.Name)
|
||||
return CreateResult{}, apperr.Conflict.Msgf("A channel named %q already exists.", p.Name)
|
||||
}
|
||||
return CreateResult{}, fmt.Errorf("insert channel: %w", err)
|
||||
}
|
||||
@ -165,11 +173,11 @@ func (s *Service) Update(ctx context.Context, channelID, teamID pgtype.UUID, nam
|
||||
name = clean
|
||||
|
||||
if len(eventTypes) == 0 {
|
||||
return db.Channel{}, fmt.Errorf("invalid: at least one event type is required")
|
||||
return db.Channel{}, apperr.ValidationFailed.Msg("At least one event type is required.").With("field", "events")
|
||||
}
|
||||
for _, et := range eventTypes {
|
||||
if !validEvents[et] {
|
||||
return db.Channel{}, fmt.Errorf("invalid: unknown event type %q", et)
|
||||
return db.Channel{}, apperr.ValidationFailed.Msgf("Unknown event type %q.", et).With("field", "events")
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,11 +189,11 @@ func (s *Service) Update(ctx context.Context, channelID, teamID pgtype.UUID, nam
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return db.Channel{}, fmt.Errorf("channel not found")
|
||||
return db.Channel{}, apperr.NotFound.Msg("Channel not found.")
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return db.Channel{}, fmt.Errorf("conflict: channel name %q already exists", name)
|
||||
return db.Channel{}, apperr.Conflict.Msgf("A channel named %q already exists.", name)
|
||||
}
|
||||
return db.Channel{}, fmt.Errorf("update channel: %w", err)
|
||||
}
|
||||
@ -198,7 +206,7 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
|
||||
ch, err := s.DB.GetChannelByTeam(ctx, db.GetChannelByTeamParams{ID: channelID, TeamID: teamID})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return db.Channel{}, fmt.Errorf("channel not found")
|
||||
return db.Channel{}, apperr.NotFound.Msg("Channel not found.")
|
||||
}
|
||||
return db.Channel{}, fmt.Errorf("get channel: %w", err)
|
||||
}
|
||||
@ -206,10 +214,15 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
|
||||
// Validate required config fields for this provider.
|
||||
for _, field := range requiredFields[ch.Provider] {
|
||||
if config[field] == "" {
|
||||
return db.Channel{}, fmt.Errorf("invalid: %s is required for %s", field, ch.Provider)
|
||||
return db.Channel{}, apperr.ValidationFailed.Msgf("The %s field is required for %s.", field, ch.Provider).With("field", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Reject destination URLs pointing at internal/private addresses (SSRF).
|
||||
if err := validateConfigURLs(config, s.AllowPrivateTargets); err != nil {
|
||||
return db.Channel{}, err
|
||||
}
|
||||
|
||||
// For webhooks, auto-generate secret if not provided.
|
||||
if ch.Provider == "webhook" && config["secret"] == "" {
|
||||
config["secret"] = generateSecret()
|
||||
@ -237,7 +250,7 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return db.Channel{}, fmt.Errorf("channel not found")
|
||||
return db.Channel{}, apperr.NotFound.Msg("Channel not found.")
|
||||
}
|
||||
return db.Channel{}, fmt.Errorf("update channel config: %w", err)
|
||||
}
|
||||
@ -247,15 +260,21 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
|
||||
// Test validates config and sends a test notification without persisting anything.
|
||||
func (s *Service) Test(ctx context.Context, provider string, config map[string]string) error {
|
||||
if !validProviders[provider] {
|
||||
return fmt.Errorf("invalid: unsupported provider %q", provider)
|
||||
return apperr.ValidationFailed.Msgf("Unsupported provider %q.", provider).With("field", "provider")
|
||||
}
|
||||
|
||||
for _, field := range requiredFields[provider] {
|
||||
if config[field] == "" {
|
||||
return fmt.Errorf("invalid: %s is required for %s", field, provider)
|
||||
return apperr.ValidationFailed.Msgf("The %s field is required for %s.", field, provider).With("field", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Reject destination URLs pointing at internal/private addresses (SSRF).
|
||||
// Test is the sharpest oracle: it returns the delivery result synchronously.
|
||||
if err := validateConfigURLs(config, s.AllowPrivateTargets); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For webhooks, auto-generate a temporary secret if not provided.
|
||||
if provider == "webhook" && config["secret"] == "" {
|
||||
config["secret"] = generateSecret()
|
||||
@ -269,7 +288,7 @@ func (s *Service) Test(ctx context.Context, provider string, config map[string]s
|
||||
Resource: events.Resource{ID: "test", Type: "channel"},
|
||||
}
|
||||
|
||||
return Deliver(ctx, provider, config, testEvent)
|
||||
return Deliver(ctx, provider, config, testEvent, s.AllowPrivateTargets)
|
||||
}
|
||||
|
||||
// Delete removes a channel by ID, scoped to the given team.
|
||||
@ -284,7 +303,7 @@ func cleanName(name string) (string, error) {
|
||||
name = strings.ToLower(name)
|
||||
name = strings.ReplaceAll(name, " ", "-")
|
||||
if err := validate.SafeName(name); err != nil {
|
||||
return "", fmt.Errorf("invalid: %w", err)
|
||||
return "", apperr.ValidationFailed.WrapMsg(err, "Invalid channel name.").With("field", "name")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
112
pkg/channels/ssrf.go
Normal file
112
pkg/channels/ssrf.go
Normal file
@ -0,0 +1,112 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
)
|
||||
|
||||
// urlConfigFields are the provider config keys that carry a caller-supplied
|
||||
// destination URL. Only these can drive an outbound HTTP connection to an
|
||||
// attacker-chosen host, so they are the SSRF-relevant fields to validate.
|
||||
var urlConfigFields = []string{"url", "webhook_url", "homeserver_url"}
|
||||
|
||||
// validateConfigURLs rejects any destination URL whose host resolves to a
|
||||
// non-public address. When allowPrivate is true (self-hosted deployments that
|
||||
// legitimately deliver to internal endpoints) the check is skipped entirely.
|
||||
func validateConfigURLs(config map[string]string, allowPrivate bool) error {
|
||||
if allowPrivate {
|
||||
return nil
|
||||
}
|
||||
for _, field := range urlConfigFields {
|
||||
raw := config[field]
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateTargetURL(raw); err != nil {
|
||||
return apperr.ValidationFailed.
|
||||
Msgf("The %s field must be a reachable public URL: %v", field, err).
|
||||
With("field", field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTargetURL parses raw as an http(s) URL and rejects it if the host
|
||||
// resolves to any non-public address. Resolution happens here (parse time) for
|
||||
// fast user feedback; the dial-time guard (dialControl) re-checks at connect
|
||||
// time to defeat DNS rebinding.
|
||||
func validateTargetURL(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL")
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("scheme %q is not allowed", u.Scheme)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("missing host")
|
||||
}
|
||||
|
||||
// A literal IP needs no DNS lookup.
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf("host is a non-public address")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve host")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf("host resolves to a non-public address")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isBlockedIP reports whether ip belongs to a range that must never be a
|
||||
// notification target: loopback, RFC1918/RFC4193 private, link-local
|
||||
// (which includes the 169.254.169.254 cloud metadata endpoint), multicast,
|
||||
// and the unspecified address.
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified()
|
||||
}
|
||||
|
||||
// dialContext returns a DialContext that blocks connections to non-public
|
||||
// addresses. The Control hook runs after DNS resolution with the concrete
|
||||
// resolved IP, so a host that passed parse-time validation but rebinds to a
|
||||
// private address is still rejected at connect time.
|
||||
func dialContext(allowPrivate bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
if !allowPrivate {
|
||||
dialer.Control = func(_, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && isBlockedIP(ip) {
|
||||
return fmt.Errorf("dial to non-public address %s blocked", ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return dialer.DialContext
|
||||
}
|
||||
@ -18,14 +18,18 @@ type WebhookDelivery struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewWebhookDelivery constructs a webhook delivery client.
|
||||
func NewWebhookDelivery() *WebhookDelivery {
|
||||
// NewWebhookDelivery constructs a webhook delivery client. When allowPrivate is
|
||||
// false the client refuses to connect to non-public addresses (SSRF guard).
|
||||
func NewWebhookDelivery(allowPrivate bool) *WebhookDelivery {
|
||||
return &WebhookDelivery{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
DialContext: dialContext(allowPrivate),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,12 @@ type Config struct {
|
||||
EncryptionKeyHex string // WRENN_ENCRYPTION_KEY raw hex string (for validation)
|
||||
EncryptionKey [32]byte // parsed 32-byte key
|
||||
|
||||
// ChannelsAllowPrivateTargets permits notification channels (webhooks etc.)
|
||||
// to point at private/loopback/link-local addresses. Off by default to
|
||||
// prevent SSRF against internal services; enable only on self-hosted
|
||||
// deployments that legitimately deliver to internal endpoints.
|
||||
ChannelsAllowPrivateTargets bool // WRENN_CHANNELS_ALLOW_PRIVATE
|
||||
|
||||
// SMTP — transactional email. All fields optional; omitting SMTPHost disables email.
|
||||
SMTPHost string // SMTP_HOST
|
||||
SMTPPort int // SMTP_PORT (default 587)
|
||||
@ -47,7 +53,7 @@ func Load() Config {
|
||||
cfg := Config{
|
||||
DatabaseURL: envOrDefault("DATABASE_URL", "postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable"),
|
||||
RedisURL: envOrDefault("REDIS_URL", "redis://localhost:6379/0"),
|
||||
ListenAddr: envOrDefault("WRENN_CP_LISTEN_ADDR", ":8080"),
|
||||
ListenAddr: envOrDefault("WRENN_CP_LISTEN_ADDR", ":9725"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
WrennDir: envOrDefault("WRENN_DIR", "/var/lib/wrenn"),
|
||||
|
||||
@ -61,6 +67,8 @@ func Load() Config {
|
||||
|
||||
EncryptionKeyHex: os.Getenv("WRENN_ENCRYPTION_KEY"),
|
||||
|
||||
ChannelsAllowPrivateTargets: envOrDefaultBool("WRENN_CHANNELS_ALLOW_PRIVATE", false),
|
||||
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: envOrDefaultInt("SMTP_PORT", 587),
|
||||
SMTPUsername: os.Getenv("SMTP_USERNAME"),
|
||||
@ -96,3 +104,15 @@ func envOrDefaultInt(key string, def int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func envOrDefaultBool(key string, def bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@ -150,8 +150,8 @@ func Run(opts ...Option) {
|
||||
os.Exit(1)
|
||||
}
|
||||
channelPub := channels.NewPublisher(rdb)
|
||||
channelSvc := &channels.Service{DB: queries, EncKey: cfg.EncryptionKey}
|
||||
channelDispatcher := channels.NewDispatcher(rdb, queries, cfg.EncryptionKey)
|
||||
channelSvc := &channels.Service{DB: queries, EncKey: cfg.EncryptionKey, AllowPrivateTargets: cfg.ChannelsAllowPrivateTargets}
|
||||
channelDispatcher := channels.NewDispatcher(rdb, queries, cfg.EncryptionKey, cfg.ChannelsAllowPrivateTargets)
|
||||
|
||||
// Shared audit logger with event publishing.
|
||||
al := audit.NewWithPublisher(queries, channelPub)
|
||||
|
||||
@ -103,7 +103,7 @@ func (r *LoopRegistry) ReleaseAll() {
|
||||
|
||||
// SnapshotDevice holds the state for a single dm-snapshot device.
|
||||
type SnapshotDevice struct {
|
||||
Name string // dm device name, e.g., "wrenn-sb-a1b2c3d4"
|
||||
Name string // dm device name, e.g., "wrenn-cl-a1b2c3d4"
|
||||
DevicePath string // /dev/mapper/<Name>
|
||||
CowPath string // path to the sparse CoW file
|
||||
CowLoopDev string // loop device for the CoW file
|
||||
|
||||
@ -258,11 +258,12 @@ func (c *Client) ExecStream(ctx context.Context, cmd string, args ...string) (<-
|
||||
}
|
||||
|
||||
// WriteFile writes content to a file inside the sandbox via envd's REST endpoint.
|
||||
// envd expects PUT /files?path=...&username=root with the raw file content as the body.
|
||||
// envd expects PUT /files?path=... with the raw file content as the body. No
|
||||
// username is sent, so envd resolves the sandbox default user — the same user
|
||||
// process execution runs as.
|
||||
func (c *Client) WriteFile(ctx context.Context, path string, content []byte) error {
|
||||
u := fmt.Sprintf("%s/files?%s", c.base, url.Values{
|
||||
"path": {path},
|
||||
"username": {"root"},
|
||||
"path": {path},
|
||||
}.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(content))
|
||||
@ -288,11 +289,11 @@ func (c *Client) WriteFile(ctx context.Context, path string, content []byte) err
|
||||
}
|
||||
|
||||
// ReadFile reads a file from inside the sandbox via envd's REST endpoint.
|
||||
// envd expects GET /files?path=...&username=root.
|
||||
// envd expects GET /files?path=... — no username is sent, so envd resolves the
|
||||
// sandbox default user, matching process execution.
|
||||
func (c *Client) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||
u := fmt.Sprintf("%s/files?%s", c.base, url.Values{
|
||||
"path": {path},
|
||||
"username": {"root"},
|
||||
"path": {path},
|
||||
}.Encode())
|
||||
|
||||
slog.Debug("envd read file", "url", u, "path", path)
|
||||
|
||||
@ -1 +0,0 @@
|
||||
package lifecycle
|
||||
21
pkg/netutil/netutil.go
Normal file
21
pkg/netutil/netutil.go
Normal file
@ -0,0 +1,21 @@
|
||||
// Package netutil holds small HTTP/networking helpers shared across the
|
||||
// control plane. It lives in pkg/ so both internal/api and the auth
|
||||
// middleware (and cloud extensions) can import a single implementation.
|
||||
package netutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClientIP returns the request's apparent client IP, honoring
|
||||
// X-Forwarded-For when behind a reverse proxy.
|
||||
func ClientIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
if i := strings.IndexByte(fwd, ','); i > 0 {
|
||||
return strings.TrimSpace(fwd[:i])
|
||||
}
|
||||
return strings.TrimSpace(fwd)
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -8,6 +9,10 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrNoFreeSlots is returned when every network slot index is claimed —
|
||||
// the host cannot take another sandbox until one is released.
|
||||
var ErrNoFreeSlots = errors.New("no free network slots")
|
||||
|
||||
// SlotAllocator manages network slot indices for sandboxes.
|
||||
// Each sandbox needs a unique slot index for its network addressing.
|
||||
//
|
||||
@ -58,7 +63,7 @@ func (a *SlotAllocator) Allocate() (int, error) {
|
||||
a.inUse[i] = true
|
||||
return i, nil
|
||||
}
|
||||
return 0, fmt.Errorf("no free network slots")
|
||||
return 0, ErrNoFreeSlots
|
||||
}
|
||||
|
||||
// Release frees a slot index for reuse.
|
||||
|
||||
@ -38,6 +38,12 @@ var (
|
||||
ErrNotPaused = errors.New("sandbox not paused")
|
||||
// ErrInvalidRange is returned when a metrics range parameter is invalid.
|
||||
ErrInvalidRange = errors.New("invalid range")
|
||||
// ErrTemplateNotFound is returned when a template's rootfs image does not
|
||||
// exist on this host.
|
||||
ErrTemplateNotFound = errors.New("template rootfs not found")
|
||||
// ErrEnvdNotReady is returned when the VM booted (or resumed) but envd did
|
||||
// not become healthy within the readiness budget.
|
||||
ErrEnvdNotReady = errors.New("envd not ready")
|
||||
)
|
||||
|
||||
// MinTimeoutSec is the minimum inactivity TTL accepted by Create/Resume.
|
||||
@ -386,7 +392,7 @@ func (m *Manager) Create(
|
||||
// Resolve base rootfs image.
|
||||
baseRootfs := layout.TemplateRootfs(m.cfg.WrennDir, teamID, templateID)
|
||||
if _, err := os.Stat(baseRootfs); err != nil {
|
||||
return nil, 0, fmt.Errorf("base rootfs not found at %s: %w", baseRootfs, err)
|
||||
return nil, 0, fmt.Errorf("%w: base rootfs missing at %s: %w", ErrTemplateNotFound, baseRootfs, err)
|
||||
}
|
||||
|
||||
// Acquire shared read-only loop device for the base image.
|
||||
@ -475,7 +481,7 @@ func (m *Manager) Create(
|
||||
|
||||
if err := client.WaitUntilReady(waitCtx); err != nil {
|
||||
res.rollback()
|
||||
return nil, 0, fmt.Errorf("wait for envd: %w", err)
|
||||
return nil, 0, fmt.Errorf("%w: %w", ErrEnvdNotReady, err)
|
||||
}
|
||||
|
||||
// Fetch envd version (best-effort).
|
||||
|
||||
@ -81,7 +81,7 @@ func (m *Manager) launchRestoredVM(ctx context.Context, vmCfg vm.VMConfig, hostI
|
||||
defer waitCancel()
|
||||
if err := client.WaitUntilReady(waitCtx); err != nil {
|
||||
_ = m.vm.Destroy(context.Background(), vmCfg.SandboxID)
|
||||
return nil, fmt.Errorf("wait envd: %w", err)
|
||||
return nil, fmt.Errorf("%w after resume: %w", ErrEnvdNotReady, err)
|
||||
}
|
||||
|
||||
// Best-effort balloon deflate. Free-page reporting drains pages while the
|
||||
|
||||
@ -29,7 +29,7 @@ func TestRunningStateRoundtrip(t *testing.T) {
|
||||
wrennDir := t.TempDir()
|
||||
want := &runningState{
|
||||
Version: runningStateVersion,
|
||||
ID: "sb-cafe0123",
|
||||
ID: "cl-cafe0123",
|
||||
TeamID: "00000000-0000-0000-0000-000000000000",
|
||||
TemplateID: "00000000-0000-0000-0000-000000000001",
|
||||
VCPUs: 2,
|
||||
@ -37,12 +37,12 @@ func TestRunningStateRoundtrip(t *testing.T) {
|
||||
TimeoutSec: 300,
|
||||
SlotIndex: 7,
|
||||
BaseImagePath: "/var/lib/wrenn/images/teams/x/y/rootfs.ext4",
|
||||
CowPath: "/var/lib/wrenn/sandboxes/sb-cafe0123/rootfs.cow",
|
||||
DMName: "wrenn-sb-cafe0123",
|
||||
SandboxDir: "/tmp/ch-vm-sb-cafe0123",
|
||||
CowPath: "/var/lib/wrenn/sandboxes/cl-cafe0123/rootfs.cow",
|
||||
DMName: "wrenn-cl-cafe0123",
|
||||
SandboxDir: "/tmp/ch-vm-cl-cafe0123",
|
||||
CHPID: 4242,
|
||||
CHSocket: "/tmp/ch-sb-cafe0123.sock",
|
||||
SandboxDirOverride: "/tmp/ch-vm-sb-original",
|
||||
CHSocket: "/tmp/ch-cl-cafe0123.sock",
|
||||
SandboxDirOverride: "/tmp/ch-vm-cl-original",
|
||||
LazyRestore: true,
|
||||
CreatedAt: time.Now().Truncate(0),
|
||||
Metadata: map[string]string{"kernel_version": "6.1.102"},
|
||||
@ -65,7 +65,7 @@ func TestRunningStateRoundtrip(t *testing.T) {
|
||||
|
||||
func TestRunningStateVersionMismatch(t *testing.T) {
|
||||
wrennDir := t.TempDir()
|
||||
st := &runningState{Version: runningStateVersion + 1, ID: "sb-deadbeef", SlotIndex: 1}
|
||||
st := &runningState{Version: runningStateVersion + 1, ID: "cl-deadbeef", SlotIndex: 1}
|
||||
writeTestRunningState(t, wrennDir, st)
|
||||
|
||||
if _, err := readRunningState(wrennDir, st.ID); err == nil {
|
||||
@ -75,9 +75,9 @@ func TestRunningStateVersionMismatch(t *testing.T) {
|
||||
|
||||
func TestRunningStateIDMismatch(t *testing.T) {
|
||||
wrennDir := t.TempDir()
|
||||
st := &runningState{Version: runningStateVersion, ID: "sb-other", SlotIndex: 1}
|
||||
st := &runningState{Version: runningStateVersion, ID: "cl-other", SlotIndex: 1}
|
||||
// Write the file under a directory whose name does not match st.ID.
|
||||
dir := layout.SandboxDir(wrennDir, "sb-dirname")
|
||||
dir := layout.SandboxDir(wrennDir, "cl-dirname")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -86,20 +86,20 @@ func TestRunningStateIDMismatch(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := readRunningState(wrennDir, "sb-dirname"); err == nil {
|
||||
if _, err := readRunningState(wrennDir, "cl-dirname"); err == nil {
|
||||
t.Fatal("readRunningState should reject id mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningStateMissingFile(t *testing.T) {
|
||||
if _, err := readRunningState(t.TempDir(), "sb-none"); !os.IsNotExist(err) {
|
||||
if _, err := readRunningState(t.TempDir(), "cl-none"); !os.IsNotExist(err) {
|
||||
t.Fatalf("want os.IsNotExist error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRunningState(t *testing.T) {
|
||||
wrennDir := t.TempDir()
|
||||
st := &runningState{Version: runningStateVersion, ID: "sb-gone", SlotIndex: 1}
|
||||
st := &runningState{Version: runningStateVersion, ID: "cl-gone", SlotIndex: 1}
|
||||
writeTestRunningState(t, wrennDir, st)
|
||||
|
||||
deleteRunningState(wrennDir, st.ID)
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
)
|
||||
|
||||
@ -95,9 +96,9 @@ func (s *LeastLoadedScheduler) SelectHost(ctx context.Context, teamID pgtype.UUI
|
||||
|
||||
if len(candidates) == 0 {
|
||||
if isByoc {
|
||||
return db.Host{}, fmt.Errorf("no online BYOC hosts available for team")
|
||||
return db.Host{}, apperr.CapacityUnavailable.Msg("None of your team's hosts are online. Bring a host online and try again.")
|
||||
}
|
||||
return db.Host{}, fmt.Errorf("no online platform hosts available")
|
||||
return db.Host{}, apperr.CapacityUnavailable.New()
|
||||
}
|
||||
|
||||
// Phase 2: admission control + selection — pick the highest-scoring host
|
||||
@ -119,7 +120,10 @@ func (s *LeastLoadedScheduler) SelectHost(ctx context.Context, teamID pgtype.UUI
|
||||
}
|
||||
|
||||
if best == -1 {
|
||||
return db.Host{}, fmt.Errorf("no host has sufficient resources: need %d MB memory, %d MB disk", memoryMb, diskSizeMb)
|
||||
return db.Host{}, apperr.CapacityUnavailable.
|
||||
Msg("No host has enough free resources for this sandbox. Try again shortly or request a smaller size.").
|
||||
With("memory_mb", memoryMb).
|
||||
With("disk_mb", diskSizeMb)
|
||||
}
|
||||
|
||||
return candidates[best].host, nil
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
)
|
||||
|
||||
@ -66,9 +67,9 @@ func (s *RoundRobinScheduler) SelectHost(ctx context.Context, teamID pgtype.UUID
|
||||
|
||||
if len(eligible) == 0 {
|
||||
if isByoc {
|
||||
return db.Host{}, fmt.Errorf("no online BYOC hosts available for team")
|
||||
return db.Host{}, apperr.CapacityUnavailable.Msg("None of your team's hosts are online. Bring a host online and try again.")
|
||||
}
|
||||
return db.Host{}, fmt.Errorf("no online platform hosts available")
|
||||
return db.Host{}, apperr.CapacityUnavailable.New()
|
||||
}
|
||||
|
||||
idx := s.counter.Add(1) - 1
|
||||
|
||||
@ -1 +0,0 @@
|
||||
package scheduler
|
||||
@ -1 +0,0 @@
|
||||
package scheduler
|
||||
@ -3,12 +3,15 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/validate"
|
||||
)
|
||||
|
||||
// APIKeyService provides API key operations shared between the REST API and the dashboard.
|
||||
@ -25,8 +28,13 @@ type APIKeyCreateResult struct {
|
||||
|
||||
// Create generates a new API key for the given team.
|
||||
func (s *APIKeyService) Create(ctx context.Context, teamID, userID pgtype.UUID, name string) (APIKeyCreateResult, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "Unnamed API Key"
|
||||
} else if err := validate.DisplayName(name); err != nil {
|
||||
return APIKeyCreateResult{}, apperr.ValidationFailed.
|
||||
WrapMsg(err, "API key name may only contain letters, numbers, spaces, and . _ - (max 100 characters).").
|
||||
With("field", "name")
|
||||
}
|
||||
|
||||
plaintext, hash, err := auth.GenerateAPIKey()
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/recipe"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
||||
@ -188,11 +189,11 @@ func (s *BuildService) List(ctx context.Context) ([]db.TemplateBuild, error) {
|
||||
func (s *BuildService) Cancel(ctx context.Context, buildID pgtype.UUID) error {
|
||||
build, err := s.DB.GetTemplateBuild(ctx, buildID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get build: %w", err)
|
||||
return apperr.BuildNotFound.Wrap(err)
|
||||
}
|
||||
switch build.Status {
|
||||
case "success", "failed", "cancelled":
|
||||
return fmt.Errorf("build is already %s", build.Status)
|
||||
return apperr.Conflict.Msgf("The build is already %s.", build.Status)
|
||||
}
|
||||
|
||||
// Mark cancelled in DB first. This handles both pending builds (which haven't
|
||||
@ -441,13 +442,14 @@ func (s *BuildService) provisionBuildSandbox(
|
||||
) (buildAgentClient, string, map[string]string, error) {
|
||||
host, err := s.Scheduler.SelectHost(ctx, id.PlatformTeamID, false, build.MemoryMb, 5120)
|
||||
if err != nil {
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("no host available: %v", err))
|
||||
// Persist the client-safe message — this string surfaces in the build UI.
|
||||
s.failBuild(ctx, buildID, apperr.From(err).Message)
|
||||
return nil, "", nil, err
|
||||
}
|
||||
|
||||
agent, err := s.Pool.GetForHost(host)
|
||||
if err != nil {
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("agent client error: %v", err))
|
||||
s.failBuild(ctx, buildID, apperr.From(err).Message)
|
||||
return nil, "", nil, err
|
||||
}
|
||||
|
||||
@ -459,7 +461,7 @@ func (s *BuildService) provisionBuildSandbox(
|
||||
// platform-owned rows, so resolve the path from the DB record.
|
||||
baseTmpl, err := s.DB.GetPlatformTemplateByName(ctx, build.BaseTemplate)
|
||||
if err != nil {
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("base template %q not found: %v", build.BaseTemplate, err))
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("base template %q not found", build.BaseTemplate))
|
||||
return nil, "", nil, err
|
||||
}
|
||||
baseTeamID := baseTmpl.TeamID
|
||||
@ -476,7 +478,7 @@ func (s *BuildService) provisionBuildSandbox(
|
||||
DiskSizeMb: 0,
|
||||
}))
|
||||
if err != nil {
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("create sandbox failed: %v", err))
|
||||
s.failBuild(ctx, buildID, apperr.From(err).Message)
|
||||
return nil, "", nil, err
|
||||
}
|
||||
sandboxMetadata := resp.Msg.Metadata
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"connectrpc.com/connect"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
||||
@ -76,7 +77,7 @@ func clampTimeout(timeoutSec int32) int32 {
|
||||
func (s *SandboxService) agentForSandbox(ctx context.Context, sandboxID pgtype.UUID) (hostagentClient, db.Sandbox, error) {
|
||||
sb, err := s.DB.GetSandbox(ctx, sandboxID)
|
||||
if err != nil {
|
||||
return nil, db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
|
||||
return nil, db.Sandbox{}, apperr.SandboxNotFound.Wrap(err)
|
||||
}
|
||||
agent, err := s.agentForHost(ctx, sb.HostID)
|
||||
if err != nil {
|
||||
@ -90,7 +91,7 @@ func (s *SandboxService) agentForSandbox(ctx context.Context, sandboxID pgtype.U
|
||||
func (s *SandboxService) agentForHost(ctx context.Context, hostID pgtype.UUID) (hostagentClient, error) {
|
||||
host, err := s.DB.GetHost(ctx, hostID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("host not found: %w", err)
|
||||
return nil, apperr.HostNotFound.Wrap(err)
|
||||
}
|
||||
agent, err := s.Pool.GetForHost(host)
|
||||
if err != nil {
|
||||
@ -126,10 +127,10 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
p.Template = "minimal-ubuntu"
|
||||
}
|
||||
if err := validate.SafeName(p.Template); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("invalid template name: %w", err)
|
||||
return db.Sandbox{}, apperr.ValidationFailed.Msgf("Invalid template name: %v.", err)
|
||||
}
|
||||
if err := validate.Metadata(p.Metadata); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("invalid metadata: %w", err)
|
||||
return db.Sandbox{}, apperr.ValidationFailed.Msgf("Invalid metadata: %v.", err)
|
||||
}
|
||||
if p.VCPUs <= 0 {
|
||||
p.VCPUs = 2
|
||||
@ -144,7 +145,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
// query also matches platform templates for any team).
|
||||
tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID})
|
||||
if err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("template %q not found: %w", p.Template, err)
|
||||
return db.Sandbox{}, apperr.TemplateNotFound.WrapMsg(err, fmt.Sprintf("Template %q not found.", p.Template))
|
||||
}
|
||||
templateTeamID := tmpl.TeamID
|
||||
templateID := tmpl.ID
|
||||
@ -159,12 +160,12 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
}
|
||||
|
||||
if !p.TeamID.Valid {
|
||||
return db.Sandbox{}, fmt.Errorf("invalid request: team_id is required")
|
||||
return db.Sandbox{}, apperr.InvalidRequest.Msg("A team_id is required.")
|
||||
}
|
||||
|
||||
team, err := s.DB.GetTeam(ctx, p.TeamID)
|
||||
if err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("team not found: %w", err)
|
||||
return db.Sandbox{}, apperr.TeamNotFound.Wrap(err)
|
||||
}
|
||||
|
||||
host, err := s.Scheduler.SelectHost(ctx, p.TeamID, team.IsByoc, p.MemoryMB, 0)
|
||||
@ -324,7 +325,7 @@ func (s *SandboxService) Get(ctx context.Context, sandboxID, teamID pgtype.UUID)
|
||||
func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID pgtype.UUID) (db.Sandbox, error) {
|
||||
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
|
||||
if err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
|
||||
return db.Sandbox{}, apperr.SandboxNotFound.Wrap(err)
|
||||
}
|
||||
if sb.Status == "paused" {
|
||||
return sb, nil
|
||||
@ -333,7 +334,7 @@ func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID pgtype.UUI
|
||||
if _, err := s.DB.UpdateSandboxStatusIf(ctx, db.UpdateSandboxStatusIfParams{
|
||||
ID: sandboxID, Status: "running", Status_2: "pausing",
|
||||
}); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("sandbox not in running state (current: %s)", sb.Status)
|
||||
return db.Sandbox{}, apperr.SandboxNotRunning.Wrap(err).With("status", sb.Status)
|
||||
}
|
||||
|
||||
agent, err := s.agentForHost(ctx, sb.HostID)
|
||||
@ -400,7 +401,7 @@ func (s *SandboxService) pauseInBackground(sandboxID pgtype.UUID, sandboxIDStr,
|
||||
func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID pgtype.UUID) (db.Sandbox, error) {
|
||||
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
|
||||
if err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
|
||||
return db.Sandbox{}, apperr.SandboxNotFound.Wrap(err)
|
||||
}
|
||||
if sb.Status == "running" {
|
||||
return sb, nil
|
||||
@ -409,7 +410,7 @@ func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID pgtype.UU
|
||||
if _, err := s.DB.UpdateSandboxStatusIf(ctx, db.UpdateSandboxStatusIfParams{
|
||||
ID: sandboxID, Status: "paused", Status_2: "resuming",
|
||||
}); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("sandbox not in paused state (current: %s)", sb.Status)
|
||||
return db.Sandbox{}, apperr.SandboxNotPaused.Wrap(err).With("status", sb.Status)
|
||||
}
|
||||
|
||||
agent, err := s.agentForHost(ctx, sb.HostID)
|
||||
@ -509,10 +510,10 @@ func (s *SandboxService) resumeInBackground(
|
||||
func (s *SandboxService) CreateSnapshot(ctx context.Context, sandboxID, teamID pgtype.UUID, name string) (db.Sandbox, string, error) {
|
||||
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
|
||||
if err != nil {
|
||||
return db.Sandbox{}, "", fmt.Errorf("sandbox not found: %w", err)
|
||||
return db.Sandbox{}, "", apperr.SandboxNotFound.Wrap(err)
|
||||
}
|
||||
if sb.Status != "running" && sb.Status != "paused" {
|
||||
return db.Sandbox{}, "", fmt.Errorf("sandbox is not running or paused (status: %s)", sb.Status)
|
||||
return db.Sandbox{}, "", apperr.Conflict.Msg("Sandbox must be running or paused to snapshot.").With("status", sb.Status)
|
||||
}
|
||||
origStatus := sb.Status
|
||||
|
||||
@ -520,18 +521,18 @@ func (s *SandboxService) CreateSnapshot(ctx context.Context, sandboxID, teamID p
|
||||
name = id.NewSnapshotName()
|
||||
}
|
||||
if err := validate.SafeName(name); err != nil {
|
||||
return db.Sandbox{}, "", fmt.Errorf("invalid name: %w", err)
|
||||
return db.Sandbox{}, "", apperr.ValidationFailed.Msgf("Invalid snapshot name: %v.", err)
|
||||
}
|
||||
// Reject duplicate names up front so we don't pause the VM and dump memory
|
||||
// only to fail on the template insert at the very end.
|
||||
if _, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: teamID}); err == nil {
|
||||
return db.Sandbox{}, "", fmt.Errorf("conflict: a snapshot named %q already exists", name)
|
||||
return db.Sandbox{}, "", apperr.Conflict.Msgf("A snapshot named %q already exists.", name)
|
||||
}
|
||||
|
||||
if _, err := s.DB.UpdateSandboxStatusIf(ctx, db.UpdateSandboxStatusIfParams{
|
||||
ID: sandboxID, Status: origStatus, Status_2: "snapshotting",
|
||||
}); err != nil {
|
||||
return db.Sandbox{}, "", fmt.Errorf("sandbox not in %s state (current: %s)", origStatus, sb.Status)
|
||||
return db.Sandbox{}, "", apperr.Conflict.WrapMsg(err, fmt.Sprintf("Sandbox is no longer in %s state.", origStatus)).With("status", origStatus)
|
||||
}
|
||||
|
||||
agent, err := s.agentForHost(ctx, sb.HostID)
|
||||
@ -639,7 +640,7 @@ func (s *SandboxService) publishStateChanged(ctx context.Context, sandboxIDStr,
|
||||
func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID pgtype.UUID) error {
|
||||
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sandbox not found: %w", err)
|
||||
return apperr.SandboxNotFound.Wrap(err)
|
||||
}
|
||||
if sb.Status == "stopped" || sb.Status == "error" {
|
||||
return nil
|
||||
@ -745,7 +746,7 @@ func (s *SandboxService) persistMetricPoints(ctx context.Context, sandboxID pgty
|
||||
func (s *SandboxService) GetDiskUsage(ctx context.Context, sandboxID, teamID pgtype.UUID) (int64, error) {
|
||||
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sandbox not found: %w", err)
|
||||
return 0, apperr.SandboxNotFound.Wrap(err)
|
||||
}
|
||||
|
||||
// For running or paused sandboxes, try the agent for live disk usage.
|
||||
@ -776,10 +777,10 @@ func (s *SandboxService) GetDiskUsage(ctx context.Context, sandboxID, teamID pgt
|
||||
func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID pgtype.UUID) error {
|
||||
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sandbox not found: %w", err)
|
||||
return apperr.SandboxNotFound.Wrap(err)
|
||||
}
|
||||
if sb.Status != "running" {
|
||||
return fmt.Errorf("sandbox is not running (status: %s)", sb.Status)
|
||||
return apperr.SandboxNotRunning.New().With("status", sb.Status)
|
||||
}
|
||||
|
||||
agent, _, err := s.agentForSandbox(ctx, sandboxID)
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
@ -56,7 +57,7 @@ func (s *TeamService) callerRole(ctx context.Context, teamID, callerUserID pgtyp
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return "", fmt.Errorf("forbidden: not a member of this team")
|
||||
return "", apperr.Forbidden.Msg("You are not a member of this team.")
|
||||
}
|
||||
return "", fmt.Errorf("get membership: %w", err)
|
||||
}
|
||||
@ -66,7 +67,7 @@ func (s *TeamService) callerRole(ctx context.Context, teamID, callerUserID pgtyp
|
||||
// requireAdmin returns an error if the caller is not an admin or owner.
|
||||
func requireAdmin(role string) error {
|
||||
if role != "owner" && role != "admin" {
|
||||
return fmt.Errorf("forbidden: admin or owner role required")
|
||||
return apperr.Forbidden.Msg("Admin or owner role required.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -76,12 +77,12 @@ func (s *TeamService) GetTeam(ctx context.Context, teamID pgtype.UUID) (db.Team,
|
||||
team, err := s.DB.GetTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return db.Team{}, fmt.Errorf("team not found")
|
||||
return db.Team{}, apperr.TeamNotFound.New()
|
||||
}
|
||||
return db.Team{}, fmt.Errorf("get team: %w", err)
|
||||
}
|
||||
if team.DeletedAt.Valid {
|
||||
return db.Team{}, fmt.Errorf("team not found")
|
||||
return db.Team{}, apperr.TeamNotFound.New()
|
||||
}
|
||||
return team, nil
|
||||
}
|
||||
@ -105,7 +106,7 @@ func (s *TeamService) ListTeamsForUser(ctx context.Context, userID pgtype.UUID)
|
||||
// CreateTeam creates a new team owned by the given user.
|
||||
func (s *TeamService) CreateTeam(ctx context.Context, ownerUserID pgtype.UUID, name string) (TeamWithRole, error) {
|
||||
if !teamNameRE.MatchString(name) {
|
||||
return TeamWithRole{}, fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _")
|
||||
return TeamWithRole{}, apperr.ValidationFailed.Msg("Team name must be 1–128 characters: letters, numbers, spaces, and underscores.").With("field", "name")
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
@ -145,7 +146,7 @@ func (s *TeamService) CreateTeam(ctx context.Context, ownerUserID pgtype.UUID, n
|
||||
// RenameTeam updates the team name. Caller must be admin or owner (verified from DB).
|
||||
func (s *TeamService) RenameTeam(ctx context.Context, teamID, callerUserID pgtype.UUID, newName string) error {
|
||||
if !teamNameRE.MatchString(newName) {
|
||||
return fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _")
|
||||
return apperr.ValidationFailed.Msg("Team name must be 1–128 characters: letters, numbers, spaces, and underscores.").With("field", "name")
|
||||
}
|
||||
|
||||
role, err := s.callerRole(ctx, teamID, callerUserID)
|
||||
@ -171,7 +172,7 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp
|
||||
return err
|
||||
}
|
||||
if role != "owner" {
|
||||
return fmt.Errorf("forbidden: only the owner can delete a team")
|
||||
return apperr.Forbidden.Msg("Only the owner can delete a team.")
|
||||
}
|
||||
|
||||
return s.deleteTeamCore(ctx, teamID)
|
||||
@ -323,7 +324,7 @@ func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID pgtype
|
||||
target, err := s.DB.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return MemberInfo{}, fmt.Errorf("user not found: no account with that email")
|
||||
return MemberInfo{}, apperr.UserNotFound.Msg("No account exists with that email.")
|
||||
}
|
||||
return MemberInfo{}, fmt.Errorf("look up user: %w", err)
|
||||
}
|
||||
@ -334,7 +335,7 @@ func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID pgtype
|
||||
TeamID: teamID,
|
||||
})
|
||||
if memberCheckErr == nil {
|
||||
return MemberInfo{}, fmt.Errorf("invalid: user is already a member of this team")
|
||||
return MemberInfo{}, apperr.Conflict.Msg("This user is already a member of the team.")
|
||||
} else if memberCheckErr != pgx.ErrNoRows {
|
||||
return MemberInfo{}, fmt.Errorf("check membership: %w", memberCheckErr)
|
||||
}
|
||||
@ -368,13 +369,13 @@ func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, ta
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return fmt.Errorf("not found: user is not a member of this team")
|
||||
return apperr.NotFound.Msg("This user is not a member of the team.")
|
||||
}
|
||||
return fmt.Errorf("get target membership: %w", err)
|
||||
}
|
||||
|
||||
if targetMembership.Role == "owner" {
|
||||
return fmt.Errorf("forbidden: the owner cannot be removed from the team")
|
||||
return apperr.Forbidden.Msg("The owner cannot be removed from the team.")
|
||||
}
|
||||
|
||||
if err := s.DB.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{
|
||||
@ -411,7 +412,7 @@ func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, ta
|
||||
// Valid target roles: "admin", "member".
|
||||
func (s *TeamService) UpdateMemberRole(ctx context.Context, teamID, callerUserID, targetUserID pgtype.UUID, newRole string) error {
|
||||
if newRole != "admin" && newRole != "member" {
|
||||
return fmt.Errorf("invalid: role must be admin or member")
|
||||
return apperr.ValidationFailed.Msg("Role must be either admin or member.").With("field", "role")
|
||||
}
|
||||
|
||||
callerRole, err := s.callerRole(ctx, teamID, callerUserID)
|
||||
@ -428,13 +429,13 @@ func (s *TeamService) UpdateMemberRole(ctx context.Context, teamID, callerUserID
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return fmt.Errorf("not found: user is not a member of this team")
|
||||
return apperr.NotFound.Msg("This user is not a member of the team.")
|
||||
}
|
||||
return fmt.Errorf("get target membership: %w", err)
|
||||
}
|
||||
|
||||
if targetMembership.Role == "owner" {
|
||||
return fmt.Errorf("forbidden: the owner's role cannot be changed")
|
||||
return apperr.Forbidden.Msg("The owner's role cannot be changed.")
|
||||
}
|
||||
|
||||
if err := s.DB.UpdateMemberRole(ctx, db.UpdateMemberRoleParams{
|
||||
@ -455,7 +456,7 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID pgtype
|
||||
return err
|
||||
}
|
||||
if role == "owner" {
|
||||
return fmt.Errorf("forbidden: the owner cannot leave the team; delete the team instead")
|
||||
return apperr.Forbidden.Msg("The owner cannot leave the team; delete the team instead.")
|
||||
}
|
||||
|
||||
if err := s.DB.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{
|
||||
@ -473,13 +474,13 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID pgtype
|
||||
func (s *TeamService) SetBYOC(ctx context.Context, teamID pgtype.UUID, enabled bool) error {
|
||||
team, err := s.DB.GetTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("team not found: %w", err)
|
||||
return apperr.TeamNotFound.Wrap(err)
|
||||
}
|
||||
if team.DeletedAt.Valid {
|
||||
return fmt.Errorf("team not found")
|
||||
return apperr.TeamNotFound.New()
|
||||
}
|
||||
if !enabled {
|
||||
return fmt.Errorf("invalid request: BYOC cannot be disabled once enabled")
|
||||
return apperr.Conflict.Msg("BYOC cannot be disabled once enabled.")
|
||||
}
|
||||
if team.IsByoc {
|
||||
// Already enabled — idempotent, no-op.
|
||||
@ -563,10 +564,10 @@ func (s *TeamService) DeleteTeamInternal(ctx context.Context, teamID pgtype.UUID
|
||||
func (s *TeamService) AdminDeleteTeam(ctx context.Context, teamID pgtype.UUID) error {
|
||||
team, err := s.DB.GetTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("team not found: %w", err)
|
||||
return apperr.TeamNotFound.Wrap(err)
|
||||
}
|
||||
if team.DeletedAt.Valid {
|
||||
return fmt.Errorf("team not found")
|
||||
return apperr.TeamNotFound.New()
|
||||
}
|
||||
|
||||
return s.deleteTeamCore(ctx, teamID)
|
||||
|
||||
78
pkg/validate/displayname_test.go
Normal file
78
pkg/validate/displayname_test.go
Normal file
@ -0,0 +1,78 @@
|
||||
package validate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"simple", "John", false},
|
||||
{"with-space", "John Doe", false},
|
||||
{"with-dot", "John D.", false},
|
||||
{"with-dash", "Anne-Marie", false},
|
||||
{"with-underscore", "cool_name", false},
|
||||
{"numbers", "user123", false},
|
||||
{"max-length", repeat("a", 100), false},
|
||||
|
||||
{"empty", "", true},
|
||||
{"too-long", repeat("a", 101), true},
|
||||
{"angle-open", "John<", true},
|
||||
{"angle-close", "John>", true},
|
||||
{"ampersand", "A&B", true},
|
||||
{"double-quote", "he\"llo", true},
|
||||
{"single-quote", "O'Brien", true},
|
||||
{"xss-img", "<img src=x onerror=alert(1)>", true},
|
||||
{"xss-script", "<script>alert(1)</script>", true},
|
||||
{"at-sign", "a@b", true},
|
||||
{"unicode-letter", "José", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := DisplayName(tt.input); (err != nil) != tt.wantErr {
|
||||
t.Errorf("DisplayName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
fallback string
|
||||
want string
|
||||
}{
|
||||
{"clean", "John Doe", "user", "John Doe"},
|
||||
{"strip-html", "<img src=x>Bob", "user", "img srcxBob"},
|
||||
{"strip-angles-only", "a<b>c", "user", "abc"},
|
||||
{"strip-unicode", "José", "user", "Jos"},
|
||||
{"all-stripped", "<>&\"'", "user", "user"},
|
||||
{"empty", "", "fallback", "fallback"},
|
||||
{"trims", " spaced ", "user", "spaced"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SanitizeDisplayName(tt.input, tt.fallback); got != tt.want {
|
||||
t.Errorf("SanitizeDisplayName(%q, %q) = %q, want %q", tt.input, tt.fallback, got, tt.want)
|
||||
}
|
||||
// A sanitized non-fallback result must itself be a valid DisplayName.
|
||||
if got := SanitizeDisplayName(tt.input, tt.fallback); got != tt.fallback {
|
||||
if err := DisplayName(got); err != nil {
|
||||
t.Errorf("SanitizeDisplayName(%q) = %q is not a valid DisplayName: %v", tt.input, got, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func repeat(s string, n int) string {
|
||||
out := make([]byte, 0, len(s)*n)
|
||||
for range n {
|
||||
out = append(out, s...)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
27
pkg/validate/email.go
Normal file
27
pkg/validate/email.go
Normal file
@ -0,0 +1,27 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// emailRe is a pragmatic email check: a local part of common address
|
||||
// characters, an @, a dotted domain, and a 2+ character TLD. It excludes
|
||||
// spaces and the HTML-significant characters < > & " ' so a stored email is
|
||||
// safe to render without escaping.
|
||||
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// Email validates that email is a well-formed address within length limits.
|
||||
// Callers should TrimSpace (and typically ToLower) first.
|
||||
func Email(email string) error {
|
||||
if email == "" {
|
||||
return fmt.Errorf("email must not be empty")
|
||||
}
|
||||
if len(email) > 254 {
|
||||
return fmt.Errorf("email is too long (max 254 characters)")
|
||||
}
|
||||
if !emailRe.MatchString(email) {
|
||||
return fmt.Errorf("email is not a valid address")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
35
pkg/validate/email_test.go
Normal file
35
pkg/validate/email_test.go
Normal file
@ -0,0 +1,35 @@
|
||||
package validate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"simple", "user@example.com", false},
|
||||
{"subdomain", "user@mail.example.com", false},
|
||||
{"plus-tag", "user+tag@example.com", false},
|
||||
{"dots", "first.last@example.co.uk", false},
|
||||
{"digits", "user123@ex4mple.io", false},
|
||||
|
||||
{"empty", "", true},
|
||||
{"no-at", "userexample.com", true},
|
||||
{"no-domain", "user@", true},
|
||||
{"no-tld", "user@example", true},
|
||||
{"space", "user @example.com", true},
|
||||
{"angle-bracket", "user@<script>.com", true},
|
||||
{"xss-payload", "a@b.com<img src=x onerror=alert(1)>", true},
|
||||
{"quote", "us\"er@example.com", true},
|
||||
{"leading-space", " user@example.com", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := Email(tt.input); (err != nil) != tt.wantErr {
|
||||
t.Errorf("Email(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -3,12 +3,50 @@ package validate
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// nameRe matches safe path component names: alphanumeric start, then
|
||||
// alphanumeric, dash, underscore, or dot. Max 64 characters.
|
||||
var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
|
||||
|
||||
// displayNameRe matches user-facing display names (user names, API key names):
|
||||
// letters, digits, spaces, dots, underscores, hyphens. It deliberately
|
||||
// excludes the HTML-significant characters < > & " ' so these values are safe
|
||||
// to render without escaping. Max 100 characters.
|
||||
var displayNameRe = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,100}$`)
|
||||
|
||||
// disallowedNameChars matches any character not permitted in a display name.
|
||||
var disallowedNameChars = regexp.MustCompile(`[^A-Za-z0-9 ._-]`)
|
||||
|
||||
// DisplayName validates a user-supplied display name. Callers should TrimSpace
|
||||
// first. It rejects empty names, names over 100 characters, and any character
|
||||
// outside the alphanumeric + space + . _ - allowlist.
|
||||
func DisplayName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("name must not be empty")
|
||||
}
|
||||
if !displayNameRe.MatchString(name) {
|
||||
return fmt.Errorf("name may only contain letters, numbers, spaces, and . _ - (max 100 characters)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanitizeDisplayName coerces an externally-sourced name (e.g. from an OAuth
|
||||
// provider) into the DisplayName allowlist by dropping disallowed characters.
|
||||
// Such names cannot be rejected interactively, so they are cleaned instead.
|
||||
// Returns fallback if nothing usable remains.
|
||||
func SanitizeDisplayName(name, fallback string) string {
|
||||
cleaned := strings.TrimSpace(disallowedNameChars.ReplaceAllString(name, ""))
|
||||
if len(cleaned) > 100 {
|
||||
cleaned = strings.TrimSpace(cleaned[:100])
|
||||
}
|
||||
if cleaned == "" {
|
||||
return fallback
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// SafeName checks that name is safe for use as a single filesystem path
|
||||
// component. It rejects empty strings, path separators, ".." sequences,
|
||||
// leading dots, and anything outside the alphanumeric+dash+underscore+dot
|
||||
|
||||
@ -27,7 +27,7 @@ type VMConfig struct {
|
||||
KernelPath string
|
||||
|
||||
// RootfsPath is the path to the rootfs block device for this sandbox.
|
||||
// Typically a dm-snapshot device (e.g., /dev/mapper/wrenn-sb-a1b2c3d4).
|
||||
// Typically a dm-snapshot device (e.g., /dev/mapper/wrenn-cl-a1b2c3d4).
|
||||
RootfsPath string
|
||||
|
||||
// VCPUs is the number of virtual CPUs to allocate (default: 1).
|
||||
|
||||
@ -4256,6 +4256,85 @@ func (*ConnectProcessResponse_Data) isConnectProcessResponse_Event() {}
|
||||
|
||||
func (*ConnectProcessResponse_End) isConnectProcessResponse_Event() {}
|
||||
|
||||
// ErrorInfo travels as a Connect error detail so the control plane can
|
||||
// surface precise, client-safe errors instead of guessing from Connect
|
||||
// codes. Fields mirror pkg/apperr.Error; details values are stringified.
|
||||
type ErrorInfo struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` // apperr catalog code, e.g. "sandbox_not_running"
|
||||
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // client-safe message
|
||||
Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"`
|
||||
HttpStatus int32 `protobuf:"varint,4,opt,name=http_status,json=httpStatus,proto3" json:"http_status,omitempty"`
|
||||
Details map[string]string `protobuf:"bytes,5,rep,name=details,proto3" json:"details,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ErrorInfo) Reset() {
|
||||
*x = ErrorInfo{}
|
||||
mi := &file_hostagent_proto_msgTypes[71]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ErrorInfo) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ErrorInfo) ProtoMessage() {}
|
||||
|
||||
func (x *ErrorInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_hostagent_proto_msgTypes[71]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ErrorInfo.ProtoReflect.Descriptor instead.
|
||||
func (*ErrorInfo) Descriptor() ([]byte, []int) {
|
||||
return file_hostagent_proto_rawDescGZIP(), []int{71}
|
||||
}
|
||||
|
||||
func (x *ErrorInfo) GetCode() string {
|
||||
if x != nil {
|
||||
return x.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ErrorInfo) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ErrorInfo) GetRetryable() bool {
|
||||
if x != nil {
|
||||
return x.Retryable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *ErrorInfo) GetHttpStatus() int32 {
|
||||
if x != nil {
|
||||
return x.HttpStatus
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ErrorInfo) GetDetails() map[string]string {
|
||||
if x != nil {
|
||||
return x.Details
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_hostagent_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_hostagent_proto_rawDesc = "" +
|
||||
@ -4594,7 +4673,17 @@ const file_hostagent_proto_rawDesc = "" +
|
||||
"\x05start\x18\x01 \x01(\v2\x1d.hostagent.v1.ExecStreamStartH\x00R\x05start\x122\n" +
|
||||
"\x04data\x18\x02 \x01(\v2\x1c.hostagent.v1.ExecStreamDataH\x00R\x04data\x12/\n" +
|
||||
"\x03end\x18\x03 \x01(\v2\x1b.hostagent.v1.ExecStreamEndH\x00R\x03endB\a\n" +
|
||||
"\x05event2\xb3\x14\n" +
|
||||
"\x05event\"\xf4\x01\n" +
|
||||
"\tErrorInfo\x12\x12\n" +
|
||||
"\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" +
|
||||
"\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" +
|
||||
"\tretryable\x18\x03 \x01(\bR\tretryable\x12\x1f\n" +
|
||||
"\vhttp_status\x18\x04 \x01(\x05R\n" +
|
||||
"httpStatus\x12>\n" +
|
||||
"\adetails\x18\x05 \x03(\v2$.hostagent.v1.ErrorInfo.DetailsEntryR\adetails\x1a:\n" +
|
||||
"\fDetailsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x012\xb3\x14\n" +
|
||||
"\x10HostAgentService\x12X\n" +
|
||||
"\rCreateSandbox\x12\".hostagent.v1.CreateSandboxRequest\x1a#.hostagent.v1.CreateSandboxResponse\x12[\n" +
|
||||
"\x0eDestroySandbox\x12#.hostagent.v1.DestroySandboxRequest\x1a$.hostagent.v1.DestroySandboxResponse\x12U\n" +
|
||||
@ -4642,7 +4731,7 @@ func file_hostagent_proto_rawDescGZIP() []byte {
|
||||
return file_hostagent_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 79)
|
||||
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 81)
|
||||
var file_hostagent_proto_goTypes = []any{
|
||||
(*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest
|
||||
(*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse
|
||||
@ -4715,23 +4804,25 @@ var file_hostagent_proto_goTypes = []any{
|
||||
(*KillProcessResponse)(nil), // 68: hostagent.v1.KillProcessResponse
|
||||
(*ConnectProcessRequest)(nil), // 69: hostagent.v1.ConnectProcessRequest
|
||||
(*ConnectProcessResponse)(nil), // 70: hostagent.v1.ConnectProcessResponse
|
||||
nil, // 71: hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
|
||||
nil, // 72: hostagent.v1.CreateSandboxResponse.MetadataEntry
|
||||
nil, // 73: hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
|
||||
nil, // 74: hostagent.v1.ResumeSandboxResponse.MetadataEntry
|
||||
nil, // 75: hostagent.v1.ExecRequest.EnvsEntry
|
||||
nil, // 76: hostagent.v1.SandboxInfo.MetadataEntry
|
||||
nil, // 77: hostagent.v1.PtyAttachRequest.EnvsEntry
|
||||
nil, // 78: hostagent.v1.StartBackgroundRequest.EnvsEntry
|
||||
(*ErrorInfo)(nil), // 71: hostagent.v1.ErrorInfo
|
||||
nil, // 72: hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
|
||||
nil, // 73: hostagent.v1.CreateSandboxResponse.MetadataEntry
|
||||
nil, // 74: hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
|
||||
nil, // 75: hostagent.v1.ResumeSandboxResponse.MetadataEntry
|
||||
nil, // 76: hostagent.v1.ExecRequest.EnvsEntry
|
||||
nil, // 77: hostagent.v1.SandboxInfo.MetadataEntry
|
||||
nil, // 78: hostagent.v1.PtyAttachRequest.EnvsEntry
|
||||
nil, // 79: hostagent.v1.StartBackgroundRequest.EnvsEntry
|
||||
nil, // 80: hostagent.v1.ErrorInfo.DetailsEntry
|
||||
}
|
||||
var file_hostagent_proto_depIdxs = []int32{
|
||||
71, // 0: hostagent.v1.CreateSandboxRequest.default_env:type_name -> hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
|
||||
72, // 1: hostagent.v1.CreateSandboxResponse.metadata:type_name -> hostagent.v1.CreateSandboxResponse.MetadataEntry
|
||||
73, // 2: hostagent.v1.ResumeSandboxRequest.default_env:type_name -> hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
|
||||
74, // 3: hostagent.v1.ResumeSandboxResponse.metadata:type_name -> hostagent.v1.ResumeSandboxResponse.MetadataEntry
|
||||
75, // 4: hostagent.v1.ExecRequest.envs:type_name -> hostagent.v1.ExecRequest.EnvsEntry
|
||||
72, // 0: hostagent.v1.CreateSandboxRequest.default_env:type_name -> hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
|
||||
73, // 1: hostagent.v1.CreateSandboxResponse.metadata:type_name -> hostagent.v1.CreateSandboxResponse.MetadataEntry
|
||||
74, // 2: hostagent.v1.ResumeSandboxRequest.default_env:type_name -> hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
|
||||
75, // 3: hostagent.v1.ResumeSandboxResponse.metadata:type_name -> hostagent.v1.ResumeSandboxResponse.MetadataEntry
|
||||
76, // 4: hostagent.v1.ExecRequest.envs:type_name -> hostagent.v1.ExecRequest.EnvsEntry
|
||||
16, // 5: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo
|
||||
76, // 6: hostagent.v1.SandboxInfo.metadata:type_name -> hostagent.v1.SandboxInfo.MetadataEntry
|
||||
77, // 6: hostagent.v1.SandboxInfo.metadata:type_name -> hostagent.v1.SandboxInfo.MetadataEntry
|
||||
23, // 7: hostagent.v1.ExecStreamResponse.start:type_name -> hostagent.v1.ExecStreamStart
|
||||
24, // 8: hostagent.v1.ExecStreamResponse.data:type_name -> hostagent.v1.ExecStreamData
|
||||
25, // 9: hostagent.v1.ExecStreamResponse.end:type_name -> hostagent.v1.ExecStreamEnd
|
||||
@ -4742,80 +4833,81 @@ var file_hostagent_proto_depIdxs = []int32{
|
||||
42, // 14: hostagent.v1.FlushSandboxMetricsResponse.points_10m:type_name -> hostagent.v1.MetricPoint
|
||||
42, // 15: hostagent.v1.FlushSandboxMetricsResponse.points_2h:type_name -> hostagent.v1.MetricPoint
|
||||
42, // 16: hostagent.v1.FlushSandboxMetricsResponse.points_24h:type_name -> hostagent.v1.MetricPoint
|
||||
77, // 17: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry
|
||||
78, // 17: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry
|
||||
53, // 18: hostagent.v1.PtyAttachResponse.started:type_name -> hostagent.v1.PtyStarted
|
||||
54, // 19: hostagent.v1.PtyAttachResponse.output:type_name -> hostagent.v1.PtyOutput
|
||||
55, // 20: hostagent.v1.PtyAttachResponse.exited:type_name -> hostagent.v1.PtyExited
|
||||
78, // 21: hostagent.v1.StartBackgroundRequest.envs:type_name -> hostagent.v1.StartBackgroundRequest.EnvsEntry
|
||||
79, // 21: hostagent.v1.StartBackgroundRequest.envs:type_name -> hostagent.v1.StartBackgroundRequest.EnvsEntry
|
||||
65, // 22: hostagent.v1.ListProcessesResponse.processes:type_name -> hostagent.v1.ProcessEntry
|
||||
23, // 23: hostagent.v1.ConnectProcessResponse.start:type_name -> hostagent.v1.ExecStreamStart
|
||||
24, // 24: hostagent.v1.ConnectProcessResponse.data:type_name -> hostagent.v1.ExecStreamData
|
||||
25, // 25: hostagent.v1.ConnectProcessResponse.end:type_name -> hostagent.v1.ExecStreamEnd
|
||||
0, // 26: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
|
||||
2, // 27: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
|
||||
4, // 28: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
|
||||
6, // 29: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
|
||||
12, // 30: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
|
||||
14, // 31: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
|
||||
17, // 32: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
|
||||
19, // 33: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
|
||||
31, // 34: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest
|
||||
34, // 35: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest
|
||||
36, // 36: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest
|
||||
8, // 37: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
|
||||
10, // 38: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
|
||||
21, // 39: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
|
||||
26, // 40: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
|
||||
29, // 41: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
|
||||
38, // 42: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
|
||||
40, // 43: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
|
||||
43, // 44: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest
|
||||
45, // 45: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest
|
||||
47, // 46: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest
|
||||
51, // 47: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest
|
||||
56, // 48: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest
|
||||
58, // 49: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest
|
||||
60, // 50: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest
|
||||
62, // 51: hostagent.v1.HostAgentService.StartBackground:input_type -> hostagent.v1.StartBackgroundRequest
|
||||
64, // 52: hostagent.v1.HostAgentService.ListProcesses:input_type -> hostagent.v1.ListProcessesRequest
|
||||
67, // 53: hostagent.v1.HostAgentService.KillProcess:input_type -> hostagent.v1.KillProcessRequest
|
||||
69, // 54: hostagent.v1.HostAgentService.ConnectProcess:input_type -> hostagent.v1.ConnectProcessRequest
|
||||
49, // 55: hostagent.v1.HostAgentService.GetTemplateSize:input_type -> hostagent.v1.GetTemplateSizeRequest
|
||||
1, // 56: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
|
||||
3, // 57: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
|
||||
5, // 58: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
|
||||
7, // 59: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
|
||||
13, // 60: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
|
||||
15, // 61: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
|
||||
18, // 62: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
|
||||
20, // 63: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
|
||||
32, // 64: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse
|
||||
35, // 65: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse
|
||||
37, // 66: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse
|
||||
9, // 67: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
|
||||
11, // 68: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
|
||||
22, // 69: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
|
||||
28, // 70: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
|
||||
30, // 71: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
|
||||
39, // 72: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
|
||||
41, // 73: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
|
||||
44, // 74: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse
|
||||
46, // 75: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse
|
||||
48, // 76: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse
|
||||
52, // 77: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse
|
||||
57, // 78: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse
|
||||
59, // 79: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse
|
||||
61, // 80: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse
|
||||
63, // 81: hostagent.v1.HostAgentService.StartBackground:output_type -> hostagent.v1.StartBackgroundResponse
|
||||
66, // 82: hostagent.v1.HostAgentService.ListProcesses:output_type -> hostagent.v1.ListProcessesResponse
|
||||
68, // 83: hostagent.v1.HostAgentService.KillProcess:output_type -> hostagent.v1.KillProcessResponse
|
||||
70, // 84: hostagent.v1.HostAgentService.ConnectProcess:output_type -> hostagent.v1.ConnectProcessResponse
|
||||
50, // 85: hostagent.v1.HostAgentService.GetTemplateSize:output_type -> hostagent.v1.GetTemplateSizeResponse
|
||||
56, // [56:86] is the sub-list for method output_type
|
||||
26, // [26:56] is the sub-list for method input_type
|
||||
26, // [26:26] is the sub-list for extension type_name
|
||||
26, // [26:26] is the sub-list for extension extendee
|
||||
0, // [0:26] is the sub-list for field type_name
|
||||
80, // 26: hostagent.v1.ErrorInfo.details:type_name -> hostagent.v1.ErrorInfo.DetailsEntry
|
||||
0, // 27: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
|
||||
2, // 28: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
|
||||
4, // 29: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
|
||||
6, // 30: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
|
||||
12, // 31: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
|
||||
14, // 32: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
|
||||
17, // 33: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
|
||||
19, // 34: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
|
||||
31, // 35: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest
|
||||
34, // 36: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest
|
||||
36, // 37: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest
|
||||
8, // 38: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
|
||||
10, // 39: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
|
||||
21, // 40: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
|
||||
26, // 41: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
|
||||
29, // 42: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
|
||||
38, // 43: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
|
||||
40, // 44: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
|
||||
43, // 45: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest
|
||||
45, // 46: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest
|
||||
47, // 47: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest
|
||||
51, // 48: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest
|
||||
56, // 49: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest
|
||||
58, // 50: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest
|
||||
60, // 51: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest
|
||||
62, // 52: hostagent.v1.HostAgentService.StartBackground:input_type -> hostagent.v1.StartBackgroundRequest
|
||||
64, // 53: hostagent.v1.HostAgentService.ListProcesses:input_type -> hostagent.v1.ListProcessesRequest
|
||||
67, // 54: hostagent.v1.HostAgentService.KillProcess:input_type -> hostagent.v1.KillProcessRequest
|
||||
69, // 55: hostagent.v1.HostAgentService.ConnectProcess:input_type -> hostagent.v1.ConnectProcessRequest
|
||||
49, // 56: hostagent.v1.HostAgentService.GetTemplateSize:input_type -> hostagent.v1.GetTemplateSizeRequest
|
||||
1, // 57: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
|
||||
3, // 58: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
|
||||
5, // 59: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
|
||||
7, // 60: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
|
||||
13, // 61: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
|
||||
15, // 62: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
|
||||
18, // 63: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
|
||||
20, // 64: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
|
||||
32, // 65: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse
|
||||
35, // 66: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse
|
||||
37, // 67: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse
|
||||
9, // 68: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
|
||||
11, // 69: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
|
||||
22, // 70: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
|
||||
28, // 71: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
|
||||
30, // 72: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
|
||||
39, // 73: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
|
||||
41, // 74: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
|
||||
44, // 75: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse
|
||||
46, // 76: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse
|
||||
48, // 77: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse
|
||||
52, // 78: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse
|
||||
57, // 79: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse
|
||||
59, // 80: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse
|
||||
61, // 81: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse
|
||||
63, // 82: hostagent.v1.HostAgentService.StartBackground:output_type -> hostagent.v1.StartBackgroundResponse
|
||||
66, // 83: hostagent.v1.HostAgentService.ListProcesses:output_type -> hostagent.v1.ListProcessesResponse
|
||||
68, // 84: hostagent.v1.HostAgentService.KillProcess:output_type -> hostagent.v1.KillProcessResponse
|
||||
70, // 85: hostagent.v1.HostAgentService.ConnectProcess:output_type -> hostagent.v1.ConnectProcessResponse
|
||||
50, // 86: hostagent.v1.HostAgentService.GetTemplateSize:output_type -> hostagent.v1.GetTemplateSizeResponse
|
||||
57, // [57:87] is the sub-list for method output_type
|
||||
27, // [27:57] is the sub-list for method input_type
|
||||
27, // [27:27] is the sub-list for extension type_name
|
||||
27, // [27:27] is the sub-list for extension extendee
|
||||
0, // [0:27] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_hostagent_proto_init() }
|
||||
@ -4861,7 +4953,7 @@ func file_hostagent_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 79,
|
||||
NumMessages: 81,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@ -595,3 +595,14 @@ message ConnectProcessResponse {
|
||||
ExecStreamEnd end = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorInfo travels as a Connect error detail so the control plane can
|
||||
// surface precise, client-safe errors instead of guessing from Connect
|
||||
// codes. Fields mirror pkg/apperr.Error; details values are stringified.
|
||||
message ErrorInfo {
|
||||
string code = 1; // apperr catalog code, e.g. "sandbox_not_running"
|
||||
string message = 2; // client-safe message
|
||||
bool retryable = 3;
|
||||
int32 http_status = 4;
|
||||
map<string, string> details = 5;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user