Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19b0ce5586 | |||
| 20bdaf3248 | |||
| 6c1fe84185 | |||
| 44d7ee977f | |||
| 9bbc20011a | |||
| ecf7344d21 | |||
| fdceba32d7 | |||
| d68e2f477e | |||
| f9c5f50596 | |||
| 308f619ae5 | |||
| 31aa0fd08b | |||
| 179ea7768e | |||
| 97fda34770 | |||
| 758bd7e287 | |||
| 76dee02f99 | |||
| bf1dd581fb | |||
| 760af814d9 | |||
| cf6f9ef018 | |||
| e564e914cd | |||
| 4c4dd5c99d | |||
| 174b8b06f3 | |||
| e2928f35ee | |||
| 4ae56f2fd6 | |||
| f217c9f855 | |||
| 0d56e7066d | |||
| 6f95706f5d | |||
| 355d6eee22 | |||
| a693e2554a | |||
| b412cdd91a | |||
| 2185aea300 | |||
| f6e7976300 | |||
| 3ef31d73c5 | |||
| 929a319f09 | |||
| 197139915f | |||
| 506c0c9e66 | |||
| fbb8d5f6ab | |||
| e8e22306c1 | |||
| c246d4864d | |||
| 44ea0f106f | |||
| b3474da27b | |||
| daa67c40e8 | |||
| 1660111e92 | |||
| efac6273b7 | |||
| ee4a146400 | |||
| 405bb442d9 | |||
| b8c109ff53 | |||
| 4c1d293066 | |||
| c22769c87f | |||
| 6966a55c5a | |||
| d323decce1 | |||
| 6004982361 | |||
| 9725ea2dd8 | |||
| c055af8ddd | |||
| be63cabfad | |||
| 1dbe0d4664 | |||
| 22a67b8ee8 | |||
| 86373ead1a | |||
| d358b087ea | |||
| 3461572d0b | |||
| d0085d2dbe | |||
| 032938279e | |||
| 3e84596fc2 | |||
| 85e3e19673 | |||
| 52febdb0ef | |||
| 7134021388 | |||
| fc9cad154c | |||
| 402cd8edf4 | |||
| 758fd11aeb | |||
| 09a7ab3c60 | |||
| d3f50a07a9 | |||
| 9434940fd6 | |||
| 476cd08fa6 | |||
| 88d019c1de | |||
| c161306ed6 | |||
| 04d4634b7c | |||
| dca7f1ede4 | |||
| 0a1f3660a9 | |||
| 184ae244fd | |||
| 47abc5e190 | |||
| 02353d36d0 | |||
| 750e883540 | |||
| ad313e7298 | |||
| c7036561f4 | |||
| 1080169274 | |||
| ae06584e62 | |||
| 1f23f4e8b2 | |||
| 9dc6c3c6e9 | |||
| 4446f59262 | |||
| fe8b59600c | |||
| 56e056626e | |||
| de73ec8c6a | |||
| 09db46b4fd | |||
| fb9a9cf075 | |||
| 7a1032d6ed | |||
| 44338a2bf3 | |||
| 1a093ebdc2 | |||
| bb5c04dd92 | |||
| 8eff5a2f29 | |||
| 9cf4811ede | |||
| 745cd43b4c | |||
| bfa3c341e6 | |||
| 40ef295cef | |||
| 4e8e581448 | |||
| 5062c5a251 | |||
| 813ee5d403 | |||
| 5c0c1162a9 | |||
| a3c1ddfc3d | |||
| d8053cb7fd | |||
| ac6f9aaff9 | |||
| a24df6ea71 | |||
| db27a5a49a | |||
| d23f78bb33 | |||
| aacea6a8cf | |||
| 0c65031450 | |||
| 0b72adf15b | |||
| 9df29448ff | |||
| e68a6bc89a | |||
| dc80e044fa | |||
| 41d4f81200 | |||
| cca70d85d0 | |||
| 2535920770 | |||
| e4acf33c30 | |||
| 2daa25b47e | |||
| f9b38be2f3 | |||
| 270e52537d | |||
| e409f3d656 | |||
| 3d506178ed | |||
| d67c8e49e6 | |||
| 205c7204ef | |||
| 6125f01e7d | |||
| 5625d4fcf5 | |||
| ec9bdf126e | |||
| 5bab1f33ec | |||
| 89aef9f5d1 | |||
| 40b555238f | |||
| 5af4118e7a | |||
| fab998c6e0 | |||
| 9e8539eae2 | |||
| 44ea2e63b8 |
@@ -1,13 +1,13 @@
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: pr${PR_NUMBER}-tls
|
||||
name: ${DEPLOY_NAME}-tls
|
||||
namespace: pr-deployment-certs
|
||||
spec:
|
||||
secretName: pr${PR_NUMBER}-tls
|
||||
secretName: ${DEPLOY_NAME}-tls
|
||||
issuerRef:
|
||||
name: letsencrypt
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- "${PR_HOSTNAME}"
|
||||
- "*.${PR_HOSTNAME}"
|
||||
- "${DEPLOY_HOSTNAME}"
|
||||
- "*.${DEPLOY_HOSTNAME}"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: coder-workspace-pr${PR_NUMBER}
|
||||
namespace: pr${PR_NUMBER}
|
||||
name: coder-workspace-${DEPLOY_NAME}
|
||||
namespace: ${DEPLOY_NAME}
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: coder-workspace-pr${PR_NUMBER}
|
||||
namespace: pr${PR_NUMBER}
|
||||
name: coder-workspace-${DEPLOY_NAME}
|
||||
namespace: ${DEPLOY_NAME}
|
||||
rules:
|
||||
- apiGroups: ["*"]
|
||||
resources: ["*"]
|
||||
@@ -19,13 +19,13 @@ rules:
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: coder-workspace-pr${PR_NUMBER}
|
||||
namespace: pr${PR_NUMBER}
|
||||
name: coder-workspace-${DEPLOY_NAME}
|
||||
namespace: ${DEPLOY_NAME}
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: coder-workspace-pr${PR_NUMBER}
|
||||
namespace: pr${PR_NUMBER}
|
||||
name: coder-workspace-${DEPLOY_NAME}
|
||||
namespace: ${DEPLOY_NAME}
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: coder-workspace-pr${PR_NUMBER}
|
||||
name: coder-workspace-${DEPLOY_NAME}
|
||||
|
||||
@@ -12,9 +12,23 @@ terraform {
|
||||
provider "coder" {
|
||||
}
|
||||
|
||||
variable "use_kubeconfig" {
|
||||
type = bool
|
||||
description = <<-EOF
|
||||
Use host kubeconfig? (true/false)
|
||||
|
||||
Set this to false if the Coder host is itself running as a Pod on the same
|
||||
Kubernetes cluster as you are deploying workspaces to.
|
||||
|
||||
Set this to true if the Coder host is running outside the Kubernetes cluster
|
||||
for workspaces. A valid "~/.kube/config" must be present on the Coder host.
|
||||
EOF
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "namespace" {
|
||||
type = string
|
||||
description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces)"
|
||||
description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces). If the Coder host is itself running as a Pod on the same Kubernetes cluster as you are deploying workspaces to, set this to the same namespace."
|
||||
}
|
||||
|
||||
data "coder_parameter" "cpu" {
|
||||
@@ -82,7 +96,8 @@ data "coder_parameter" "home_disk_size" {
|
||||
}
|
||||
|
||||
provider "kubernetes" {
|
||||
config_path = null
|
||||
# Authenticate via ~/.kube/config or a Coder-specific ServiceAccount, depending on admin preferences
|
||||
config_path = var.use_kubeconfig == true ? "~/.kube/config" : null
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
@@ -94,10 +109,12 @@ resource "coder_agent" "main" {
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
|
||||
# install and start code-server
|
||||
# Install the latest code-server.
|
||||
# Append "--version x.x.x" to install a specific version of code-server.
|
||||
curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server
|
||||
/tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 &
|
||||
|
||||
# Start code-server in the background.
|
||||
/tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 &
|
||||
EOT
|
||||
|
||||
# The following metadata blocks are optional. They are used to display
|
||||
@@ -174,13 +191,13 @@ resource "coder_app" "code-server" {
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_persistent_volume_claim" "home" {
|
||||
resource "kubernetes_persistent_volume_claim_v1" "home" {
|
||||
metadata {
|
||||
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-home"
|
||||
name = "coder-${data.coder_workspace.me.id}-home"
|
||||
namespace = var.namespace
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-pvc"
|
||||
"app.kubernetes.io/instance" = "coder-pvc-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
|
||||
"app.kubernetes.io/instance" = "coder-pvc-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
//Coder-specific labels.
|
||||
"com.coder.resource" = "true"
|
||||
@@ -204,18 +221,18 @@ resource "kubernetes_persistent_volume_claim" "home" {
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_deployment" "main" {
|
||||
resource "kubernetes_deployment_v1" "main" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
depends_on = [
|
||||
kubernetes_persistent_volume_claim.home
|
||||
kubernetes_persistent_volume_claim_v1.home
|
||||
]
|
||||
wait_for_rollout = false
|
||||
metadata {
|
||||
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
|
||||
name = "coder-${data.coder_workspace.me.id}"
|
||||
namespace = var.namespace
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
@@ -232,7 +249,14 @@ resource "kubernetes_deployment" "main" {
|
||||
replicas = 1
|
||||
selector {
|
||||
match_labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
}
|
||||
strategy {
|
||||
@@ -242,20 +266,29 @@ resource "kubernetes_deployment" "main" {
|
||||
template {
|
||||
metadata {
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
}
|
||||
spec {
|
||||
hostname = lower(data.coder_workspace.me.name)
|
||||
|
||||
security_context {
|
||||
run_as_user = 1000
|
||||
fs_group = 1000
|
||||
run_as_user = 1000
|
||||
fs_group = 1000
|
||||
run_as_non_root = true
|
||||
}
|
||||
|
||||
service_account_name = "coder-workspace-${var.namespace}"
|
||||
container {
|
||||
name = "dev"
|
||||
image = "bencdr/devops-tools"
|
||||
image_pull_policy = "Always"
|
||||
image = "codercom/enterprise-base:ubuntu"
|
||||
image_pull_policy = "IfNotPresent"
|
||||
command = ["sh", "-c", coder_agent.main.init_script]
|
||||
security_context {
|
||||
run_as_user = "1000"
|
||||
@@ -284,7 +317,7 @@ resource "kubernetes_deployment" "main" {
|
||||
volume {
|
||||
name = "home"
|
||||
persistent_volume_claim {
|
||||
claim_name = kubernetes_persistent_volume_claim.home.metadata.0.name
|
||||
claim_name = kubernetes_persistent_volume_claim_v1.home.metadata.0.name
|
||||
read_only = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
coder:
|
||||
podAnnotations:
|
||||
deploy-sha: "${GITHUB_SHA}"
|
||||
image:
|
||||
repo: "${REPO}"
|
||||
tag: "pr${PR_NUMBER}"
|
||||
tag: "${DEPLOY_NAME}"
|
||||
pullPolicy: Always
|
||||
service:
|
||||
type: ClusterIP
|
||||
ingress:
|
||||
enable: true
|
||||
className: traefik
|
||||
host: "${PR_HOSTNAME}"
|
||||
wildcardHost: "*.${PR_HOSTNAME}"
|
||||
host: "${DEPLOY_HOSTNAME}"
|
||||
wildcardHost: "*.${DEPLOY_HOSTNAME}"
|
||||
tls:
|
||||
enable: true
|
||||
secretName: "pr${PR_NUMBER}-tls"
|
||||
wildcardSecretName: "pr${PR_NUMBER}-tls"
|
||||
secretName: "${DEPLOY_NAME}-tls"
|
||||
wildcardSecretName: "${DEPLOY_NAME}-tls"
|
||||
env:
|
||||
- name: "CODER_ACCESS_URL"
|
||||
value: "https://${PR_HOSTNAME}"
|
||||
value: "https://${DEPLOY_HOSTNAME}"
|
||||
- name: "CODER_WILDCARD_ACCESS_URL"
|
||||
value: "*.${PR_HOSTNAME}"
|
||||
value: "*.${DEPLOY_HOSTNAME}"
|
||||
- name: "CODER_EXPERIMENTS"
|
||||
value: "${EXPERIMENTS}"
|
||||
- name: CODER_PG_CONNECTION_URL
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
name: Deploy Branch
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
packages: write
|
||||
env:
|
||||
CODER_IMAGE_TAG: "ghcr.io/coder/coder-preview:${{ github.ref_name }}"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Setup sqlc
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod download
|
||||
make gen/mark-fresh
|
||||
export DOCKER_IMAGE_NO_PREREQUISITES=true
|
||||
version="$(./scripts/version.sh)"
|
||||
CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")"
|
||||
export CODER_IMAGE_BUILD_BASE_TAG
|
||||
make -j build/coder_linux_amd64
|
||||
./scripts/build_docker.sh \
|
||||
--arch amd64 \
|
||||
--target "${CODER_IMAGE_TAG}" \
|
||||
--version "$version" \
|
||||
--push \
|
||||
build/coder_linux_amd64
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.ref_name }}
|
||||
DEPLOY_NAME: "${{ github.ref_name }}"
|
||||
TEST_DOMAIN_SUFFIX: "${{ startsWith(secrets.PR_DEPLOYMENTS_DOMAIN, 'test.') && secrets.PR_DEPLOYMENTS_DOMAIN || format('test.{0}', secrets.PR_DEPLOYMENTS_DOMAIN) }}"
|
||||
BRANCH_HOSTNAME: "${{ github.ref_name }}.${{ startsWith(secrets.PR_DEPLOYMENTS_DOMAIN, 'test.') && secrets.PR_DEPLOYMENTS_DOMAIN || format('test.{0}', secrets.PR_DEPLOYMENTS_DOMAIN) }}"
|
||||
CODER_IMAGE_TAG: "ghcr.io/coder/coder-preview:${{ github.ref_name }}"
|
||||
REPO: ghcr.io/coder/coder-preview
|
||||
EXPERIMENTS: "*,oauth2,mcp-server-http"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up kubeconfig
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Verify cluster authentication
|
||||
run: |
|
||||
set -euo pipefail
|
||||
kubectl auth can-i get namespaces > /dev/null
|
||||
|
||||
- name: Check if deployment exists
|
||||
id: check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
set +e
|
||||
helm_status_output="$(helm status "${DEPLOY_NAME}" --namespace "${DEPLOY_NAME}" 2>&1)"
|
||||
helm_status_code=$?
|
||||
set -e
|
||||
|
||||
if [ "$helm_status_code" -eq 0 ]; then
|
||||
echo "new=false" >> "$GITHUB_OUTPUT"
|
||||
elif echo "$helm_status_output" | grep -qi "release: not found"; then
|
||||
echo "new=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "$helm_status_output"
|
||||
exit "$helm_status_code"
|
||||
fi
|
||||
|
||||
# ---- Every push: ensure routing + TLS ----
|
||||
|
||||
- name: Ensure DNS records
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
api_base_url="https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records"
|
||||
base_name="${BRANCH_HOSTNAME}"
|
||||
base_target="${TEST_DOMAIN_SUFFIX}"
|
||||
wildcard_name="*.${BRANCH_HOSTNAME}"
|
||||
|
||||
ensure_cname_record() {
|
||||
local record_name="$1"
|
||||
local record_content="$2"
|
||||
|
||||
echo "Ensuring CNAME ${record_name} -> ${record_content}."
|
||||
|
||||
set +e
|
||||
lookup_raw_response="$(
|
||||
curl -sS -G "${api_base_url}" \
|
||||
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type:application/json" \
|
||||
--data-urlencode "name=${record_name}" \
|
||||
--data-urlencode "per_page=100" \
|
||||
-w '\n%{http_code}'
|
||||
)"
|
||||
lookup_exit_code=$?
|
||||
set -e
|
||||
|
||||
if [ "$lookup_exit_code" -eq 0 ]; then
|
||||
lookup_response="${lookup_raw_response%$'\n'*}"
|
||||
lookup_http_code="${lookup_raw_response##*$'\n'}"
|
||||
|
||||
if [ "$lookup_http_code" = "200" ] && echo "$lookup_response" | jq -e '.success == true' > /dev/null 2>&1; then
|
||||
if echo "$lookup_response" | jq -e '.result[]? | select(.type != "CNAME")' > /dev/null 2>&1; then
|
||||
echo "Conflicting non-CNAME DNS record exists for ${record_name}."
|
||||
echo "$lookup_response"
|
||||
return 1
|
||||
fi
|
||||
|
||||
existing_cname_id="$(echo "$lookup_response" | jq -r '.result[]? | select(.type == "CNAME") | .id' | head -n1)"
|
||||
if [ -n "$existing_cname_id" ]; then
|
||||
existing_content="$(echo "$lookup_response" | jq -r --arg id "$existing_cname_id" '.result[] | select(.id == $id) | .content')"
|
||||
if [ "$existing_content" = "$record_content" ]; then
|
||||
echo "CNAME already set for ${record_name}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Updating existing CNAME for ${record_name}."
|
||||
update_response="$(
|
||||
curl -sS -X PUT "${api_base_url}/${existing_cname_id}" \
|
||||
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type:application/json" \
|
||||
--data '{"type":"CNAME","name":"'"${record_name}"'","content":"'"${record_content}"'","ttl":1,"proxied":false}'
|
||||
)"
|
||||
|
||||
if echo "$update_response" | jq -e '.success == true' > /dev/null 2>&1; then
|
||||
echo "Updated CNAME for ${record_name}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Cloudflare API error while updating ${record_name}:"
|
||||
echo "$update_response"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Could not query DNS record ${record_name}; attempting create."
|
||||
fi
|
||||
|
||||
max_attempts=6
|
||||
attempt=1
|
||||
last_response=""
|
||||
last_http_code=""
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
echo "Creating DNS record ${record_name} (attempt ${attempt}/${max_attempts})."
|
||||
|
||||
set +e
|
||||
raw_response="$(
|
||||
curl -sS -X POST "${api_base_url}" \
|
||||
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type:application/json" \
|
||||
--data '{"type":"CNAME","name":"'"${record_name}"'","content":"'"${record_content}"'","ttl":1,"proxied":false}' \
|
||||
-w '\n%{http_code}'
|
||||
)"
|
||||
curl_exit_code=$?
|
||||
set -e
|
||||
|
||||
curl_failed=false
|
||||
if [ "$curl_exit_code" -eq 0 ]; then
|
||||
response="${raw_response%$'\n'*}"
|
||||
http_code="${raw_response##*$'\n'}"
|
||||
else
|
||||
response="curl exited with code ${curl_exit_code}."
|
||||
http_code="000"
|
||||
curl_failed=true
|
||||
fi
|
||||
|
||||
last_response="$response"
|
||||
last_http_code="$http_code"
|
||||
|
||||
if echo "$response" | jq -e '.success == true' > /dev/null 2>&1; then
|
||||
echo "Created DNS record ${record_name}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 81057: identical record exists. 81053: host record conflict.
|
||||
if echo "$response" | jq -e '.errors[]? | select(.code == 81057 or .code == 81053)' > /dev/null 2>&1; then
|
||||
echo "DNS record already exists for ${record_name}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
transient_error=false
|
||||
if [ "$curl_failed" = true ] || [ "$http_code" = "429" ]; then
|
||||
transient_error=true
|
||||
elif [[ "$http_code" =~ ^[0-9]{3}$ ]] && [ "$http_code" -ge 500 ] && [ "$http_code" -lt 600 ]; then
|
||||
transient_error=true
|
||||
fi
|
||||
|
||||
if echo "$response" | jq -e '.errors[]? | select(.code == 10000 or .code == 10001)' > /dev/null 2>&1; then
|
||||
transient_error=true
|
||||
fi
|
||||
|
||||
if [ "$transient_error" = true ] && [ "$attempt" -lt "$max_attempts" ]; then
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "Transient Cloudflare API error (HTTP ${http_code}). Retrying in ${sleep_seconds}s."
|
||||
sleep "$sleep_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
break
|
||||
done
|
||||
|
||||
echo "Cloudflare API error while creating DNS record ${record_name} after ${attempt} attempt(s):"
|
||||
echo "HTTP status: ${last_http_code}"
|
||||
echo "$last_response"
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_cname_record "${base_name}" "${base_target}"
|
||||
ensure_cname_record "${wildcard_name}" "${base_name}"
|
||||
|
||||
# ---- First deploy only ----
|
||||
|
||||
- name: Create namespace
|
||||
if: steps.check.outputs.new == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
kubectl delete namespace "${DEPLOY_NAME}" --wait=true || true
|
||||
# Delete any orphaned PVs that were bound to PVCs in this
|
||||
# namespace. Without this, the old PV (with stale Postgres
|
||||
# data) gets reused on reinstall, causing auth failures.
|
||||
kubectl get pv -o json | \
|
||||
jq -r '.items[] | select(.spec.claimRef.namespace=='"${DEPLOY_NAME}"') | .metadata.name' | \
|
||||
xargs -r kubectl delete pv || true
|
||||
kubectl create namespace "${DEPLOY_NAME}"
|
||||
|
||||
# ---- Every push: ensure deployment certificate ----
|
||||
|
||||
- name: Ensure certificate
|
||||
env:
|
||||
DEPLOY_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cert_secret_name="${DEPLOY_NAME}-tls"
|
||||
|
||||
envsubst < ./.github/pr-deployments/certificate.yaml | kubectl apply -f -
|
||||
|
||||
if ! kubectl -n pr-deployment-certs wait --for=condition=Ready "certificate/${cert_secret_name}" --timeout=10m; then
|
||||
echo "Timed out waiting for certificate ${cert_secret_name} to become Ready after 10 minutes."
|
||||
kubectl -n pr-deployment-certs describe certificate "${cert_secret_name}" || true
|
||||
kubectl -n pr-deployment-certs get certificaterequest,order,challenge -l "cert-manager.io/certificate-name=${cert_secret_name}" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kubectl get secret "${cert_secret_name}" -n pr-deployment-certs -o json |
|
||||
jq 'del(.metadata.namespace,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.selfLink,.metadata.uid,.metadata.managedFields)' |
|
||||
kubectl -n "${DEPLOY_NAME}" apply -f -
|
||||
|
||||
- name: Set up PostgreSQL
|
||||
if: steps.check.outputs.new == 'true'
|
||||
run: |
|
||||
helm repo add bitnami https://charts.bitnami.com/bitnami
|
||||
helm install coder-db bitnami/postgresql \
|
||||
--namespace "${DEPLOY_NAME}" \
|
||||
--set image.repository=bitnamilegacy/postgresql \
|
||||
--set auth.username=coder \
|
||||
--set auth.password=coder \
|
||||
--set auth.database=coder \
|
||||
--set persistence.size=10Gi
|
||||
kubectl create secret generic coder-db-url -n "${DEPLOY_NAME}" \
|
||||
--from-literal=url="postgres://coder:coder@coder-db-postgresql.${DEPLOY_NAME}.svc.cluster.local:5432/coder?sslmode=disable"
|
||||
|
||||
- name: Create RBAC
|
||||
if: steps.check.outputs.new == 'true'
|
||||
run: envsubst < ./.github/pr-deployments/rbac.yaml | kubectl apply -f -
|
||||
|
||||
# ---- Every push ----
|
||||
|
||||
- name: Create values.yaml
|
||||
env:
|
||||
DEPLOY_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
|
||||
REPO: ${{ env.REPO }}
|
||||
PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID }}
|
||||
PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET }}
|
||||
run: envsubst < ./.github/pr-deployments/values.yaml > ./deploy-values.yaml
|
||||
|
||||
- name: Install/Upgrade Helm chart
|
||||
run: |
|
||||
set -euo pipefail
|
||||
helm dependency update --skip-refresh ./helm/coder
|
||||
helm upgrade --install "${DEPLOY_NAME}" ./helm/coder \
|
||||
--namespace "${DEPLOY_NAME}" \
|
||||
--values ./deploy-values.yaml \
|
||||
--force
|
||||
|
||||
- name: Install coder-logstream-kube
|
||||
if: steps.check.outputs.new == 'true'
|
||||
run: |
|
||||
helm repo add coder-logstream-kube https://helm.coder.com/logstream-kube
|
||||
helm upgrade --install coder-logstream-kube coder-logstream-kube/coder-logstream-kube \
|
||||
--namespace "${DEPLOY_NAME}" \
|
||||
--set url="https://${BRANCH_HOSTNAME}" \
|
||||
--set "namespaces[0]=${DEPLOY_NAME}"
|
||||
|
||||
- name: Create first user and template
|
||||
if: steps.check.outputs.new == 'true'
|
||||
env:
|
||||
PR_DEPLOYMENTS_ADMIN_PASSWORD: ${{ secrets.PR_DEPLOYMENTS_ADMIN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
URL="https://${BRANCH_HOSTNAME}/bin/coder-linux-amd64"
|
||||
COUNT=0
|
||||
until curl --output /dev/null --silent --head --fail "$URL"; do
|
||||
sleep 5
|
||||
COUNT=$((COUNT+1))
|
||||
if [ "$COUNT" -ge 60 ]; then echo "Timed out"; exit 1; fi
|
||||
done
|
||||
curl -fsSL "$URL" -o /tmp/coder && chmod +x /tmp/coder
|
||||
|
||||
password="${PR_DEPLOYMENTS_ADMIN_PASSWORD}"
|
||||
if [ -z "$password" ]; then
|
||||
echo "Missing PR_DEPLOYMENTS_ADMIN_PASSWORD repository secret."
|
||||
exit 1
|
||||
fi
|
||||
echo "::add-mask::$password"
|
||||
|
||||
admin_username="${BRANCH_NAME}-admin"
|
||||
admin_email="${BRANCH_NAME}@coder.com"
|
||||
coder_url="https://${BRANCH_HOSTNAME}"
|
||||
|
||||
first_user_status="$(curl -sS -o /dev/null -w '%{http_code}' "${coder_url}/api/v2/users/first")"
|
||||
if [ "$first_user_status" = "404" ]; then
|
||||
/tmp/coder login \
|
||||
--first-user-username "$admin_username" \
|
||||
--first-user-email "$admin_email" \
|
||||
--first-user-password "$password" \
|
||||
--first-user-trial=false \
|
||||
--use-token-as-session \
|
||||
"$coder_url"
|
||||
elif [ "$first_user_status" = "200" ]; then
|
||||
login_payload="$(jq -n --arg email "$admin_email" --arg password "$password" '{email: $email, password: $password}')"
|
||||
login_response="$(
|
||||
curl -sS -X POST "${coder_url}/api/v2/users/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$login_payload" \
|
||||
-w '\n%{http_code}'
|
||||
)"
|
||||
login_body="${login_response%$'\n'*}"
|
||||
login_status="${login_response##*$'\n'}"
|
||||
|
||||
if [ "$login_status" != "201" ]; then
|
||||
echo "Password login failed for existing deployment (HTTP ${login_status})."
|
||||
echo "$login_body"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
session_token="$(echo "$login_body" | jq -r '.session_token // empty')"
|
||||
if [ -z "$session_token" ]; then
|
||||
echo "Password login response is missing session_token."
|
||||
exit 1
|
||||
fi
|
||||
echo "::add-mask::$session_token"
|
||||
|
||||
/tmp/coder login \
|
||||
--token "$session_token" \
|
||||
--use-token-as-session \
|
||||
"$coder_url"
|
||||
else
|
||||
echo "Unexpected status from /api/v2/users/first: ${first_user_status}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd .github/pr-deployments/template
|
||||
/tmp/coder templates push -y --directory . --variable "namespace=${DEPLOY_NAME}" kubernetes
|
||||
/tmp/coder create --template="kubernetes" kube \
|
||||
--parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y
|
||||
/tmp/coder stop kube -y
|
||||
@@ -285,6 +285,8 @@ jobs:
|
||||
PR_NUMBER: ${{ needs.get_info.outputs.PR_NUMBER }}
|
||||
PR_TITLE: ${{ needs.get_info.outputs.PR_TITLE }}
|
||||
PR_URL: ${{ needs.get_info.outputs.PR_URL }}
|
||||
DEPLOY_NAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}"
|
||||
DEPLOY_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}"
|
||||
PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -521,7 +523,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd .github/pr-deployments/template
|
||||
coder templates push -y --variable "namespace=pr${PR_NUMBER}" kubernetes
|
||||
coder templates push -y --directory . --variable "namespace=pr${PR_NUMBER}" kubernetes
|
||||
|
||||
# Create workspace
|
||||
coder create --template="kubernetes" kube --parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y
|
||||
|
||||
+90
-19
@@ -12,6 +12,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
@@ -881,7 +882,7 @@ const (
|
||||
reportConnectionBufferLimit = 2048
|
||||
)
|
||||
|
||||
func (a *agent) reportConnection(id uuid.UUID, connectionType proto.Connection_Type, ip string) (disconnected func(code int, reason string)) {
|
||||
func (a *agent) reportConnection(id uuid.UUID, connectionType proto.Connection_Type, ip string, options ...func(*proto.Connection)) (disconnected func(code int, reason string)) {
|
||||
// A blank IP can unfortunately happen if the connection is broken in a data race before we get to introspect it. We
|
||||
// still report it, and the recipient can handle a blank IP.
|
||||
if ip != "" {
|
||||
@@ -912,16 +913,20 @@ func (a *agent) reportConnection(id uuid.UUID, connectionType proto.Connection_T
|
||||
slog.F("ip", ip),
|
||||
)
|
||||
} else {
|
||||
connectMsg := &proto.Connection{
|
||||
Id: id[:],
|
||||
Action: proto.Connection_CONNECT,
|
||||
Type: connectionType,
|
||||
Timestamp: timestamppb.New(time.Now()),
|
||||
Ip: ip,
|
||||
StatusCode: 0,
|
||||
Reason: nil,
|
||||
}
|
||||
for _, opt := range options {
|
||||
opt(connectMsg)
|
||||
}
|
||||
a.reportConnections = append(a.reportConnections, &proto.ReportConnectionRequest{
|
||||
Connection: &proto.Connection{
|
||||
Id: id[:],
|
||||
Action: proto.Connection_CONNECT,
|
||||
Type: connectionType,
|
||||
Timestamp: timestamppb.New(time.Now()),
|
||||
Ip: ip,
|
||||
StatusCode: 0,
|
||||
Reason: nil,
|
||||
},
|
||||
Connection: connectMsg,
|
||||
})
|
||||
select {
|
||||
case a.reportConnectionsUpdate <- struct{}{}:
|
||||
@@ -942,16 +947,20 @@ func (a *agent) reportConnection(id uuid.UUID, connectionType proto.Connection_T
|
||||
return
|
||||
}
|
||||
|
||||
disconnMsg := &proto.Connection{
|
||||
Id: id[:],
|
||||
Action: proto.Connection_DISCONNECT,
|
||||
Type: connectionType,
|
||||
Timestamp: timestamppb.New(time.Now()),
|
||||
Ip: ip,
|
||||
StatusCode: int32(code), //nolint:gosec
|
||||
Reason: &reason,
|
||||
}
|
||||
for _, opt := range options {
|
||||
opt(disconnMsg)
|
||||
}
|
||||
a.reportConnections = append(a.reportConnections, &proto.ReportConnectionRequest{
|
||||
Connection: &proto.Connection{
|
||||
Id: id[:],
|
||||
Action: proto.Connection_DISCONNECT,
|
||||
Type: connectionType,
|
||||
Timestamp: timestamppb.New(time.Now()),
|
||||
Ip: ip,
|
||||
StatusCode: int32(code), //nolint:gosec
|
||||
Reason: &reason,
|
||||
},
|
||||
Connection: disconnMsg,
|
||||
})
|
||||
select {
|
||||
case a.reportConnectionsUpdate <- struct{}{}:
|
||||
@@ -1377,6 +1386,8 @@ func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(co
|
||||
manifest.DERPForceWebSockets,
|
||||
manifest.DisableDirectConnections,
|
||||
keySeed,
|
||||
manifest.WorkspaceName,
|
||||
manifest.Apps,
|
||||
)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tailnet: %w", err)
|
||||
@@ -1525,12 +1536,39 @@ func (a *agent) trackGoroutine(fn func()) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// appPortFromURL extracts the port from a workspace app URL,
|
||||
// defaulting to 80/443 by scheme.
|
||||
func appPortFromURL(rawURL string) uint16 {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
p := u.Port()
|
||||
if p == "" {
|
||||
switch u.Scheme {
|
||||
case "http":
|
||||
return 80
|
||||
case "https":
|
||||
return 443
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
port, err := strconv.ParseUint(p, 10, 16)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return uint16(port)
|
||||
}
|
||||
|
||||
func (a *agent) createTailnet(
|
||||
ctx context.Context,
|
||||
agentID uuid.UUID,
|
||||
derpMap *tailcfg.DERPMap,
|
||||
derpForceWebSockets, disableDirectConnections bool,
|
||||
keySeed int64,
|
||||
workspaceName string,
|
||||
apps []codersdk.WorkspaceApp,
|
||||
) (_ *tailnet.Conn, err error) {
|
||||
// Inject `CODER_AGENT_HEADER` into the DERP header.
|
||||
var header http.Header
|
||||
@@ -1539,6 +1577,18 @@ func (a *agent) createTailnet(
|
||||
header = headerTransport.Header
|
||||
}
|
||||
}
|
||||
|
||||
// Build port-to-app mapping for workspace app connection tracking
|
||||
// via the tailnet callback.
|
||||
portToApp := make(map[uint16]codersdk.WorkspaceApp)
|
||||
for _, app := range apps {
|
||||
port := appPortFromURL(app.URL)
|
||||
if port == 0 || app.External {
|
||||
continue
|
||||
}
|
||||
portToApp[port] = app
|
||||
}
|
||||
|
||||
network, err := tailnet.NewConn(&tailnet.Options{
|
||||
ID: agentID,
|
||||
Addresses: a.wireguardAddresses(agentID),
|
||||
@@ -1548,6 +1598,27 @@ func (a *agent) createTailnet(
|
||||
Logger: a.logger.Named("net.tailnet"),
|
||||
ListenPort: a.tailnetListenPort,
|
||||
BlockEndpoints: disableDirectConnections,
|
||||
ShortDescription: "Workspace Agent",
|
||||
Hostname: workspaceName,
|
||||
TCPConnCallback: func(src, dst netip.AddrPort) (disconnected func(int, string)) {
|
||||
app, ok := portToApp[dst.Port()]
|
||||
connType := proto.Connection_PORT_FORWARDING
|
||||
slugOrPort := strconv.Itoa(int(dst.Port()))
|
||||
if ok {
|
||||
connType = proto.Connection_WORKSPACE_APP
|
||||
if app.Slug != "" {
|
||||
slugOrPort = app.Slug
|
||||
}
|
||||
}
|
||||
return a.reportConnection(
|
||||
uuid.New(),
|
||||
connType,
|
||||
src.String(),
|
||||
func(c *proto.Connection) {
|
||||
c.SlugOrPort = &slugOrPort
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create tailnet: %w", err)
|
||||
|
||||
@@ -2843,6 +2843,102 @@ func TestAgent_Dial(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgent_PortForwardConnectionType verifies connection
|
||||
// type classification for forwarded TCP connections.
|
||||
func TestAgent_PortForwardConnectionType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Start a TCP echo server for the "app" port.
|
||||
appListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = appListener.Close() })
|
||||
appPort := appListener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
// Start a TCP echo server for a non-app port.
|
||||
nonAppListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = nonAppListener.Close() })
|
||||
nonAppPort := nonAppListener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
echoOnce := func(l net.Listener) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
_, _ = io.Copy(c, c)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:dogsled
|
||||
agentConn, agentClient, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
Apps: []codersdk.WorkspaceApp{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
Slug: "myapp",
|
||||
URL: fmt.Sprintf("http://localhost:%d", appPort),
|
||||
SharingLevel: codersdk.WorkspaceAppSharingLevelOwner,
|
||||
Health: codersdk.WorkspaceAppHealthDisabled,
|
||||
},
|
||||
},
|
||||
}, 0)
|
||||
require.True(t, agentConn.AwaitReachable(ctx))
|
||||
|
||||
// Phase 1: Connect to the app port, expect WORKSPACE_APP.
|
||||
appDone := echoOnce(appListener)
|
||||
conn, err := agentConn.DialContext(ctx, "tcp", appListener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
testDial(ctx, t, conn)
|
||||
_ = conn.Close()
|
||||
<-appDone
|
||||
|
||||
var reports []*proto.ReportConnectionRequest
|
||||
require.Eventually(t, func() bool {
|
||||
reports = agentClient.GetConnectionReports()
|
||||
return len(reports) >= 2
|
||||
}, testutil.WaitMedium, testutil.IntervalFast,
|
||||
"waiting for 2 connection reports for workspace app",
|
||||
)
|
||||
|
||||
require.Equal(t, proto.Connection_CONNECT, reports[0].GetConnection().GetAction())
|
||||
require.Equal(t, proto.Connection_WORKSPACE_APP, reports[0].GetConnection().GetType())
|
||||
require.Equal(t, "myapp", reports[0].GetConnection().GetSlugOrPort())
|
||||
|
||||
require.Equal(t, proto.Connection_DISCONNECT, reports[1].GetConnection().GetAction())
|
||||
require.Equal(t, proto.Connection_WORKSPACE_APP, reports[1].GetConnection().GetType())
|
||||
require.Equal(t, "myapp", reports[1].GetConnection().GetSlugOrPort())
|
||||
|
||||
// Phase 2: Connect to the non-app port, expect PORT_FORWARDING.
|
||||
nonAppDone := echoOnce(nonAppListener)
|
||||
conn, err = agentConn.DialContext(ctx, "tcp", nonAppListener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
testDial(ctx, t, conn)
|
||||
_ = conn.Close()
|
||||
<-nonAppDone
|
||||
|
||||
nonAppPortStr := strconv.Itoa(nonAppPort)
|
||||
require.Eventually(t, func() bool {
|
||||
reports = agentClient.GetConnectionReports()
|
||||
return len(reports) >= 4
|
||||
}, testutil.WaitMedium, testutil.IntervalFast,
|
||||
"waiting for 4 connection reports total",
|
||||
)
|
||||
|
||||
require.Equal(t, proto.Connection_CONNECT, reports[2].GetConnection().GetAction())
|
||||
require.Equal(t, proto.Connection_PORT_FORWARDING, reports[2].GetConnection().GetType())
|
||||
require.Equal(t, nonAppPortStr, reports[2].GetConnection().GetSlugOrPort())
|
||||
|
||||
require.Equal(t, proto.Connection_DISCONNECT, reports[3].GetConnection().GetAction())
|
||||
require.Equal(t, proto.Connection_PORT_FORWARDING, reports[3].GetConnection().GetType())
|
||||
require.Equal(t, nonAppPortStr, reports[3].GetConnection().GetSlugOrPort())
|
||||
}
|
||||
|
||||
// TestAgent_UpdatedDERP checks that agents can handle their DERP map being
|
||||
// updated, and that clients can also handle it.
|
||||
func TestAgent_UpdatedDERP(t *testing.T) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.30.0
|
||||
// protoc v4.23.4
|
||||
// protoc-gen-go v1.36.9
|
||||
// protoc v6.33.1
|
||||
// source: agent/agentsocket/proto/agentsocket.proto
|
||||
|
||||
package proto
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,18 +22,16 @@ const (
|
||||
)
|
||||
|
||||
type PingRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PingRequest) Reset() {
|
||||
*x = PingRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PingRequest) String() string {
|
||||
@@ -43,7 +42,7 @@ func (*PingRequest) ProtoMessage() {}
|
||||
|
||||
func (x *PingRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -59,18 +58,16 @@ func (*PingRequest) Descriptor() ([]byte, []int) {
|
||||
}
|
||||
|
||||
type PingResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PingResponse) Reset() {
|
||||
*x = PingResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PingResponse) String() string {
|
||||
@@ -81,7 +78,7 @@ func (*PingResponse) ProtoMessage() {}
|
||||
|
||||
func (x *PingResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -97,20 +94,17 @@ func (*PingResponse) Descriptor() ([]byte, []int) {
|
||||
}
|
||||
|
||||
type SyncStartRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncStartRequest) Reset() {
|
||||
*x = SyncStartRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncStartRequest) String() string {
|
||||
@@ -121,7 +115,7 @@ func (*SyncStartRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStartRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -144,18 +138,16 @@ func (x *SyncStartRequest) GetUnit() string {
|
||||
}
|
||||
|
||||
type SyncStartResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncStartResponse) Reset() {
|
||||
*x = SyncStartResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncStartResponse) String() string {
|
||||
@@ -166,7 +158,7 @@ func (*SyncStartResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStartResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[3]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -182,21 +174,18 @@ func (*SyncStartResponse) Descriptor() ([]byte, []int) {
|
||||
}
|
||||
|
||||
type SyncWantRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
DependsOn string `protobuf:"bytes,2,opt,name=depends_on,json=dependsOn,proto3" json:"depends_on,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
DependsOn string `protobuf:"bytes,2,opt,name=depends_on,json=dependsOn,proto3" json:"depends_on,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncWantRequest) Reset() {
|
||||
*x = SyncWantRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncWantRequest) String() string {
|
||||
@@ -207,7 +196,7 @@ func (*SyncWantRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncWantRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[4]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -237,18 +226,16 @@ func (x *SyncWantRequest) GetDependsOn() string {
|
||||
}
|
||||
|
||||
type SyncWantResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncWantResponse) Reset() {
|
||||
*x = SyncWantResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncWantResponse) String() string {
|
||||
@@ -259,7 +246,7 @@ func (*SyncWantResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncWantResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[5]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -275,20 +262,17 @@ func (*SyncWantResponse) Descriptor() ([]byte, []int) {
|
||||
}
|
||||
|
||||
type SyncCompleteRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncCompleteRequest) Reset() {
|
||||
*x = SyncCompleteRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncCompleteRequest) String() string {
|
||||
@@ -299,7 +283,7 @@ func (*SyncCompleteRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncCompleteRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[6]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -322,18 +306,16 @@ func (x *SyncCompleteRequest) GetUnit() string {
|
||||
}
|
||||
|
||||
type SyncCompleteResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncCompleteResponse) Reset() {
|
||||
*x = SyncCompleteResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncCompleteResponse) String() string {
|
||||
@@ -344,7 +326,7 @@ func (*SyncCompleteResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncCompleteResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[7]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -360,20 +342,17 @@ func (*SyncCompleteResponse) Descriptor() ([]byte, []int) {
|
||||
}
|
||||
|
||||
type SyncReadyRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncReadyRequest) Reset() {
|
||||
*x = SyncReadyRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncReadyRequest) String() string {
|
||||
@@ -384,7 +363,7 @@ func (*SyncReadyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncReadyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[8]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -407,20 +386,17 @@ func (x *SyncReadyRequest) GetUnit() string {
|
||||
}
|
||||
|
||||
type SyncReadyResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Ready bool `protobuf:"varint,1,opt,name=ready,proto3" json:"ready,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Ready bool `protobuf:"varint,1,opt,name=ready,proto3" json:"ready,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncReadyResponse) Reset() {
|
||||
*x = SyncReadyResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncReadyResponse) String() string {
|
||||
@@ -431,7 +407,7 @@ func (*SyncReadyResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncReadyResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[9]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -454,20 +430,17 @@ func (x *SyncReadyResponse) GetReady() bool {
|
||||
}
|
||||
|
||||
type SyncStatusRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncStatusRequest) Reset() {
|
||||
*x = SyncStatusRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncStatusRequest) String() string {
|
||||
@@ -478,7 +451,7 @@ func (*SyncStatusRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStatusRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[10]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -501,24 +474,21 @@ func (x *SyncStatusRequest) GetUnit() string {
|
||||
}
|
||||
|
||||
type DependencyInfo struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
DependsOn string `protobuf:"bytes,2,opt,name=depends_on,json=dependsOn,proto3" json:"depends_on,omitempty"`
|
||||
RequiredStatus string `protobuf:"bytes,3,opt,name=required_status,json=requiredStatus,proto3" json:"required_status,omitempty"`
|
||||
CurrentStatus string `protobuf:"bytes,4,opt,name=current_status,json=currentStatus,proto3" json:"current_status,omitempty"`
|
||||
IsSatisfied bool `protobuf:"varint,5,opt,name=is_satisfied,json=isSatisfied,proto3" json:"is_satisfied,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
DependsOn string `protobuf:"bytes,2,opt,name=depends_on,json=dependsOn,proto3" json:"depends_on,omitempty"`
|
||||
RequiredStatus string `protobuf:"bytes,3,opt,name=required_status,json=requiredStatus,proto3" json:"required_status,omitempty"`
|
||||
CurrentStatus string `protobuf:"bytes,4,opt,name=current_status,json=currentStatus,proto3" json:"current_status,omitempty"`
|
||||
IsSatisfied bool `protobuf:"varint,5,opt,name=is_satisfied,json=isSatisfied,proto3" json:"is_satisfied,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) Reset() {
|
||||
*x = DependencyInfo{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) String() string {
|
||||
@@ -529,7 +499,7 @@ func (*DependencyInfo) ProtoMessage() {}
|
||||
|
||||
func (x *DependencyInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[11]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -580,22 +550,19 @@ func (x *DependencyInfo) GetIsSatisfied() bool {
|
||||
}
|
||||
|
||||
type SyncStatusResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
|
||||
IsReady bool `protobuf:"varint,2,opt,name=is_ready,json=isReady,proto3" json:"is_ready,omitempty"`
|
||||
Dependencies []*DependencyInfo `protobuf:"bytes,3,rep,name=dependencies,proto3" json:"dependencies,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
|
||||
IsReady bool `protobuf:"varint,2,opt,name=is_ready,json=isReady,proto3" json:"is_ready,omitempty"`
|
||||
Dependencies []*DependencyInfo `protobuf:"bytes,3,rep,name=dependencies,proto3" json:"dependencies,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) Reset() {
|
||||
*x = SyncStatusResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) String() string {
|
||||
@@ -606,7 +573,7 @@ func (*SyncStatusResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStatusResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[12]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@@ -644,111 +611,62 @@ func (x *SyncStatusResponse) GetDependencies() []*DependencyInfo {
|
||||
|
||||
var File_agent_agentsocket_proto_agentsocket_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_rawDesc = []byte{
|
||||
0x0a, 0x29, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
|
||||
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x14, 0x63, 0x6f, 0x64,
|
||||
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76,
|
||||
0x31, 0x22, 0x0d, 0x0a, 0x0b, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x22, 0x0e, 0x0a, 0x0c, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x22, 0x26, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22, 0x13, 0x0a, 0x11, 0x53, 0x79, 0x6e, 0x63,
|
||||
0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x44, 0x0a,
|
||||
0x0f, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||
0x75, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x5f,
|
||||
0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64,
|
||||
0x73, 0x4f, 0x6e, 0x22, 0x12, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x29, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x43,
|
||||
0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12,
|
||||
0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e,
|
||||
0x69, 0x74, 0x22, 0x16, 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65,
|
||||
0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x0a, 0x10, 0x53, 0x79,
|
||||
0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12,
|
||||
0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e,
|
||||
0x69, 0x74, 0x22, 0x29, 0x0a, 0x11, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79, 0x22, 0x27, 0x0a,
|
||||
0x11, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22, 0xb6, 0x01, 0x0a, 0x0e, 0x44, 0x65, 0x70, 0x65, 0x6e,
|
||||
0x64, 0x65, 0x6e, 0x63, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69,
|
||||
0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a,
|
||||
0x0a, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x5f, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x09, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x4f, 0x6e, 0x12, 0x27, 0x0a, 0x0f,
|
||||
0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x53,
|
||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74,
|
||||
0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63,
|
||||
0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x21, 0x0a, 0x0c,
|
||||
0x69, 0x73, 0x5f, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01,
|
||||
0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x53, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x22,
|
||||
0x91, 0x01, 0x0a, 0x12, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x19,
|
||||
0x0a, 0x08, 0x69, 0x73, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08,
|
||||
0x52, 0x07, 0x69, 0x73, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x48, 0x0a, 0x0c, 0x64, 0x65, 0x70,
|
||||
0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
||||
0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
|
||||
0x79, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0c, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
|
||||
0x69, 0x65, 0x73, 0x32, 0xbb, 0x04, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f,
|
||||
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e,
|
||||
0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22,
|
||||
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
|
||||
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12,
|
||||
0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63,
|
||||
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
|
||||
0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
|
||||
0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57,
|
||||
0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x63, 0x6f,
|
||||
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e,
|
||||
0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79,
|
||||
0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12,
|
||||
0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x27,
|
||||
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
|
||||
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
|
||||
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
|
||||
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
const file_agent_agentsocket_proto_agentsocket_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
")agent/agentsocket/proto/agentsocket.proto\x12\x14coder.agentsocket.v1\"\r\n" +
|
||||
"\vPingRequest\"\x0e\n" +
|
||||
"\fPingResponse\"&\n" +
|
||||
"\x10SyncStartRequest\x12\x12\n" +
|
||||
"\x04unit\x18\x01 \x01(\tR\x04unit\"\x13\n" +
|
||||
"\x11SyncStartResponse\"D\n" +
|
||||
"\x0fSyncWantRequest\x12\x12\n" +
|
||||
"\x04unit\x18\x01 \x01(\tR\x04unit\x12\x1d\n" +
|
||||
"\n" +
|
||||
"depends_on\x18\x02 \x01(\tR\tdependsOn\"\x12\n" +
|
||||
"\x10SyncWantResponse\")\n" +
|
||||
"\x13SyncCompleteRequest\x12\x12\n" +
|
||||
"\x04unit\x18\x01 \x01(\tR\x04unit\"\x16\n" +
|
||||
"\x14SyncCompleteResponse\"&\n" +
|
||||
"\x10SyncReadyRequest\x12\x12\n" +
|
||||
"\x04unit\x18\x01 \x01(\tR\x04unit\")\n" +
|
||||
"\x11SyncReadyResponse\x12\x14\n" +
|
||||
"\x05ready\x18\x01 \x01(\bR\x05ready\"'\n" +
|
||||
"\x11SyncStatusRequest\x12\x12\n" +
|
||||
"\x04unit\x18\x01 \x01(\tR\x04unit\"\xb6\x01\n" +
|
||||
"\x0eDependencyInfo\x12\x12\n" +
|
||||
"\x04unit\x18\x01 \x01(\tR\x04unit\x12\x1d\n" +
|
||||
"\n" +
|
||||
"depends_on\x18\x02 \x01(\tR\tdependsOn\x12'\n" +
|
||||
"\x0frequired_status\x18\x03 \x01(\tR\x0erequiredStatus\x12%\n" +
|
||||
"\x0ecurrent_status\x18\x04 \x01(\tR\rcurrentStatus\x12!\n" +
|
||||
"\fis_satisfied\x18\x05 \x01(\bR\visSatisfied\"\x91\x01\n" +
|
||||
"\x12SyncStatusResponse\x12\x16\n" +
|
||||
"\x06status\x18\x01 \x01(\tR\x06status\x12\x19\n" +
|
||||
"\bis_ready\x18\x02 \x01(\bR\aisReady\x12H\n" +
|
||||
"\fdependencies\x18\x03 \x03(\v2$.coder.agentsocket.v1.DependencyInfoR\fdependencies2\xbb\x04\n" +
|
||||
"\vAgentSocket\x12M\n" +
|
||||
"\x04Ping\x12!.coder.agentsocket.v1.PingRequest\x1a\".coder.agentsocket.v1.PingResponse\x12\\\n" +
|
||||
"\tSyncStart\x12&.coder.agentsocket.v1.SyncStartRequest\x1a'.coder.agentsocket.v1.SyncStartResponse\x12Y\n" +
|
||||
"\bSyncWant\x12%.coder.agentsocket.v1.SyncWantRequest\x1a&.coder.agentsocket.v1.SyncWantResponse\x12e\n" +
|
||||
"\fSyncComplete\x12).coder.agentsocket.v1.SyncCompleteRequest\x1a*.coder.agentsocket.v1.SyncCompleteResponse\x12\\\n" +
|
||||
"\tSyncReady\x12&.coder.agentsocket.v1.SyncReadyRequest\x1a'.coder.agentsocket.v1.SyncReadyResponse\x12_\n" +
|
||||
"\n" +
|
||||
"SyncStatus\x12'.coder.agentsocket.v1.SyncStatusRequest\x1a(.coder.agentsocket.v1.SyncStatusResponseB3Z1github.com/coder/coder/v2/agent/agentsocket/protob\x06proto3"
|
||||
|
||||
var (
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescOnce sync.Once
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescData = file_agent_agentsocket_proto_agentsocket_proto_rawDesc
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP() []byte {
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescOnce.Do(func() {
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescData = protoimpl.X.CompressGZIP(file_agent_agentsocket_proto_agentsocket_proto_rawDescData)
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_agent_agentsocket_proto_agentsocket_proto_rawDesc), len(file_agent_agentsocket_proto_agentsocket_proto_rawDesc)))
|
||||
})
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_goTypes = []interface{}{
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_goTypes = []any{
|
||||
(*PingRequest)(nil), // 0: coder.agentsocket.v1.PingRequest
|
||||
(*PingResponse)(nil), // 1: coder.agentsocket.v1.PingResponse
|
||||
(*SyncStartRequest)(nil), // 2: coder.agentsocket.v1.SyncStartRequest
|
||||
@@ -789,169 +707,11 @@ func file_agent_agentsocket_proto_agentsocket_proto_init() {
|
||||
if File_agent_agentsocket_proto_agentsocket_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*PingRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*PingResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStartRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStartResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncWantRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncWantResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncCompleteRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncCompleteResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncReadyRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncReadyResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStatusRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*DependencyInfo); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStatusResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_agent_agentsocket_proto_agentsocket_proto_rawDesc,
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_agent_agentsocket_proto_agentsocket_proto_rawDesc), len(file_agent_agentsocket_proto_agentsocket_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 13,
|
||||
NumExtensions: 0,
|
||||
@@ -962,7 +722,6 @@ func file_agent_agentsocket_proto_agentsocket_proto_init() {
|
||||
MessageInfos: file_agent_agentsocket_proto_agentsocket_proto_msgTypes,
|
||||
}.Build()
|
||||
File_agent_agentsocket_proto_agentsocket_proto = out.File
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDesc = nil
|
||||
file_agent_agentsocket_proto_agentsocket_proto_goTypes = nil
|
||||
file_agent_agentsocket_proto_agentsocket_proto_depIdxs = nil
|
||||
}
|
||||
|
||||
@@ -359,6 +359,17 @@ func (s *sessionCloseTracker) Close() error {
|
||||
return s.Session.Close()
|
||||
}
|
||||
|
||||
func fallbackDisconnectReason(code int, reason string) string {
|
||||
if reason != "" || code == 0 {
|
||||
return reason
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"connection ended unexpectedly: session closed without explicit reason (exit code: %d)",
|
||||
code,
|
||||
)
|
||||
}
|
||||
|
||||
func extractContainerInfo(env []string) (container, containerUser string, filteredEnv []string) {
|
||||
for _, kv := range env {
|
||||
if strings.HasPrefix(kv, ContainerEnvironmentVariable+"=") {
|
||||
@@ -439,7 +450,8 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
|
||||
disconnected := s.config.ReportConnection(id, magicType, remoteAddrString)
|
||||
defer func() {
|
||||
disconnected(scr.exitCode(), reason)
|
||||
code := scr.exitCode()
|
||||
disconnected(code, fallbackDisconnectReason(code, reason))
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gliderssh "github.com/gliderlabs/ssh"
|
||||
@@ -102,6 +103,32 @@ func waitForChan(ctx context.Context, t *testing.T, c <-chan struct{}, msg strin
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackDisconnectReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("KeepProvidedReason", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reason := fallbackDisconnectReason(255, "network path changed")
|
||||
assert.Equal(t, "network path changed", reason)
|
||||
})
|
||||
|
||||
t.Run("KeepEmptyReasonForCleanExit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reason := fallbackDisconnectReason(0, "")
|
||||
assert.Equal(t, "", reason)
|
||||
})
|
||||
|
||||
t.Run("FallbackReasonForUnexpectedExit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reason := fallbackDisconnectReason(1, "")
|
||||
assert.True(t, strings.Contains(reason, "ended unexpectedly"))
|
||||
assert.True(t, strings.Contains(reason, "exit code: 1"))
|
||||
})
|
||||
}
|
||||
|
||||
type testSession struct {
|
||||
ctx testSSHContext
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ func TestServer_X11(t *testing.T) {
|
||||
|
||||
func TestServer_X11_EvictionLRU(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Skip("Flaky test, times out in CI")
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("X11 forwarding is only supported on Linux")
|
||||
}
|
||||
|
||||
+33
-13
@@ -576,6 +576,8 @@ const (
|
||||
Connection_VSCODE Connection_Type = 2
|
||||
Connection_JETBRAINS Connection_Type = 3
|
||||
Connection_RECONNECTING_PTY Connection_Type = 4
|
||||
Connection_WORKSPACE_APP Connection_Type = 5
|
||||
Connection_PORT_FORWARDING Connection_Type = 6
|
||||
)
|
||||
|
||||
// Enum value maps for Connection_Type.
|
||||
@@ -586,6 +588,8 @@ var (
|
||||
2: "VSCODE",
|
||||
3: "JETBRAINS",
|
||||
4: "RECONNECTING_PTY",
|
||||
5: "WORKSPACE_APP",
|
||||
6: "PORT_FORWARDING",
|
||||
}
|
||||
Connection_Type_value = map[string]int32{
|
||||
"TYPE_UNSPECIFIED": 0,
|
||||
@@ -593,6 +597,8 @@ var (
|
||||
"VSCODE": 2,
|
||||
"JETBRAINS": 3,
|
||||
"RECONNECTING_PTY": 4,
|
||||
"WORKSPACE_APP": 5,
|
||||
"PORT_FORWARDING": 6,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2858,6 +2864,7 @@ type Connection struct {
|
||||
Ip string `protobuf:"bytes,5,opt,name=ip,proto3" json:"ip,omitempty"`
|
||||
StatusCode int32 `protobuf:"varint,6,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"`
|
||||
Reason *string `protobuf:"bytes,7,opt,name=reason,proto3,oneof" json:"reason,omitempty"`
|
||||
SlugOrPort *string `protobuf:"bytes,8,opt,name=slug_or_port,json=slugOrPort,proto3,oneof" json:"slug_or_port,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Connection) Reset() {
|
||||
@@ -2941,6 +2948,13 @@ func (x *Connection) GetReason() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Connection) GetSlugOrPort() string {
|
||||
if x != nil && x.SlugOrPort != nil {
|
||||
return *x.SlugOrPort
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ReportConnectionRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -5105,7 +5119,7 @@ var file_agent_proto_agent_proto_rawDesc = []byte{
|
||||
0x74, 0x6f, 0x74, 0x61, 0x6c, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79,
|
||||
0x22, 0x26, 0x0a, 0x24, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
|
||||
0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67, 0x65,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb6, 0x03, 0x0a, 0x0a, 0x43, 0x6f, 0x6e,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x96, 0x04, 0x0a, 0x0a, 0x43, 0x6f, 0x6e,
|
||||
0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x12, 0x39, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f,
|
||||
0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x21, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
@@ -5122,18 +5136,24 @@ var file_agent_proto_agent_proto_rawDesc = []byte{
|
||||
0x70, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x63, 0x6f, 0x64, 0x65,
|
||||
0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x43, 0x6f,
|
||||
0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01,
|
||||
0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x22,
|
||||
0x3d, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x12, 0x41, 0x43, 0x54,
|
||||
0x49, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10,
|
||||
0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54, 0x10, 0x01, 0x12, 0x0e,
|
||||
0x0a, 0x0a, 0x44, 0x49, 0x53, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54, 0x10, 0x02, 0x22, 0x56,
|
||||
0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x10, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55,
|
||||
0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03,
|
||||
0x53, 0x53, 0x48, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x56, 0x53, 0x43, 0x4f, 0x44, 0x45, 0x10,
|
||||
0x02, 0x12, 0x0d, 0x0a, 0x09, 0x4a, 0x45, 0x54, 0x42, 0x52, 0x41, 0x49, 0x4e, 0x53, 0x10, 0x03,
|
||||
0x12, 0x14, 0x0a, 0x10, 0x52, 0x45, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54, 0x49, 0x4e, 0x47,
|
||||
0x5f, 0x50, 0x54, 0x59, 0x10, 0x04, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f,
|
||||
0x6e, 0x22, 0x55, 0x0a, 0x17, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65,
|
||||
0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12,
|
||||
0x25, 0x0a, 0x0c, 0x73, 0x6c, 0x75, 0x67, 0x5f, 0x6f, 0x72, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18,
|
||||
0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0a, 0x73, 0x6c, 0x75, 0x67, 0x4f, 0x72, 0x50,
|
||||
0x6f, 0x72, 0x74, 0x88, 0x01, 0x01, 0x22, 0x3d, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x12, 0x16, 0x0a, 0x12, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45,
|
||||
0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x4f, 0x4e, 0x4e,
|
||||
0x45, 0x43, 0x54, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x44, 0x49, 0x53, 0x43, 0x4f, 0x4e, 0x4e,
|
||||
0x45, 0x43, 0x54, 0x10, 0x02, 0x22, 0x7e, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a,
|
||||
0x10, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45,
|
||||
0x44, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x53, 0x53, 0x48, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06,
|
||||
0x56, 0x53, 0x43, 0x4f, 0x44, 0x45, 0x10, 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x4a, 0x45, 0x54, 0x42,
|
||||
0x52, 0x41, 0x49, 0x4e, 0x53, 0x10, 0x03, 0x12, 0x14, 0x0a, 0x10, 0x52, 0x45, 0x43, 0x4f, 0x4e,
|
||||
0x4e, 0x45, 0x43, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x50, 0x54, 0x59, 0x10, 0x04, 0x12, 0x11, 0x0a,
|
||||
0x0d, 0x57, 0x4f, 0x52, 0x4b, 0x53, 0x50, 0x41, 0x43, 0x45, 0x5f, 0x41, 0x50, 0x50, 0x10, 0x05,
|
||||
0x12, 0x13, 0x0a, 0x0f, 0x50, 0x4f, 0x52, 0x54, 0x5f, 0x46, 0x4f, 0x52, 0x57, 0x41, 0x52, 0x44,
|
||||
0x49, 0x4e, 0x47, 0x10, 0x06, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e,
|
||||
0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x73, 0x6c, 0x75, 0x67, 0x5f, 0x6f, 0x72, 0x5f, 0x70, 0x6f, 0x72,
|
||||
0x74, 0x22, 0x55, 0x0a, 0x17, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65,
|
||||
0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3a, 0x0a, 0x0a,
|
||||
0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x1a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76,
|
||||
|
||||
@@ -364,6 +364,8 @@ message Connection {
|
||||
VSCODE = 2;
|
||||
JETBRAINS = 3;
|
||||
RECONNECTING_PTY = 4;
|
||||
WORKSPACE_APP = 5;
|
||||
PORT_FORWARDING = 6;
|
||||
}
|
||||
|
||||
bytes id = 1;
|
||||
@@ -373,6 +375,7 @@ message Connection {
|
||||
string ip = 5;
|
||||
int32 status_code = 6;
|
||||
optional string reason = 7;
|
||||
optional string slug_or_port = 8;
|
||||
}
|
||||
|
||||
message ReportConnectionRequest {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package cliutil
|
||||
package hostname
|
||||
|
||||
import (
|
||||
"os"
|
||||
+3
-1
@@ -123,7 +123,9 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
spin.Start()
|
||||
}
|
||||
|
||||
opts := &workspacesdk.DialAgentOptions{}
|
||||
opts := &workspacesdk.DialAgentOptions{
|
||||
ShortDescription: "CLI ping",
|
||||
}
|
||||
|
||||
if r.verbose {
|
||||
opts.Logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
|
||||
+3
-1
@@ -107,7 +107,9 @@ func (r *RootCmd) portForward() *serpent.Command {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
|
||||
opts := &workspacesdk.DialAgentOptions{}
|
||||
opts := &workspacesdk.DialAgentOptions{
|
||||
ShortDescription: "CLI port-forward",
|
||||
}
|
||||
|
||||
logger := inv.Logger
|
||||
if r.verbose {
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ import (
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/cli/clilog"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/cliutil"
|
||||
"github.com/coder/coder/v2/cli/cliutil/hostname"
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/autobuild"
|
||||
@@ -1029,7 +1029,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
suffix := fmt.Sprintf("%d", i)
|
||||
// The suffix is added to the hostname, so we may need to trim to fit into
|
||||
// the 64 character limit.
|
||||
hostname := stringutil.Truncate(cliutil.Hostname(), 63-len(suffix))
|
||||
hostname := stringutil.Truncate(hostname.Hostname(), 63-len(suffix))
|
||||
name := fmt.Sprintf("%s-%s", hostname, suffix)
|
||||
daemonCacheDir := filepath.Join(cacheDir, fmt.Sprintf("provisioner-%d", i))
|
||||
daemon, err := newProvisionerDaemon(
|
||||
|
||||
+3
-1
@@ -97,7 +97,9 @@ func (r *RootCmd) speedtest() *serpent.Command {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
|
||||
opts := &workspacesdk.DialAgentOptions{}
|
||||
opts := &workspacesdk.DialAgentOptions{
|
||||
ShortDescription: "CLI speedtest",
|
||||
}
|
||||
if r.verbose {
|
||||
opts.Logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
+8
-3
@@ -365,6 +365,10 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
}
|
||||
return err
|
||||
}
|
||||
shortDescription := "CLI ssh"
|
||||
if stdio {
|
||||
shortDescription = "CLI ssh (stdio)"
|
||||
}
|
||||
|
||||
// If we're in stdio mode, check to see if we can use Coder Connect.
|
||||
// We don't support Coder Connect over non-stdio coder ssh yet.
|
||||
@@ -405,9 +409,10 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
}
|
||||
conn, err := wsClient.
|
||||
DialAgent(ctx, workspaceAgent.ID, &workspacesdk.DialAgentOptions{
|
||||
Logger: logger,
|
||||
BlockEndpoints: r.disableDirect,
|
||||
EnableTelemetry: !r.disableNetworkTelemetry,
|
||||
Logger: logger,
|
||||
BlockEndpoints: r.disableDirect,
|
||||
EnableTelemetry: !r.disableNetworkTelemetry,
|
||||
ShortDescription: shortDescription,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial agent: %w", err)
|
||||
|
||||
@@ -418,6 +418,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
||||
"workspace/template_version.json": src.Workspace.TemplateVersion,
|
||||
"workspace/parameters.json": src.Workspace.Parameters,
|
||||
"workspace/workspace.json": src.Workspace.Workspace,
|
||||
"workspace/workspace_sessions.json": src.Workspace.WorkspaceSessions,
|
||||
} {
|
||||
f, err := dest.Create(k)
|
||||
if err != nil {
|
||||
|
||||
+3
-2
@@ -166,8 +166,9 @@ func (r *RootCmd) vscodeSSH() *serpent.Command {
|
||||
}
|
||||
agentConn, err := workspacesdk.New(client).
|
||||
DialAgent(ctx, workspaceAgent.ID, &workspacesdk.DialAgentOptions{
|
||||
Logger: logger,
|
||||
BlockEndpoints: r.disableDirect,
|
||||
Logger: logger,
|
||||
BlockEndpoints: r.disableDirect,
|
||||
ShortDescription: "VSCode SSH",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial workspace agent: %w", err)
|
||||
|
||||
@@ -202,11 +202,13 @@ func New(opts Options, workspace database.Workspace) *API {
|
||||
}
|
||||
|
||||
api.ConnLogAPI = &ConnLogAPI{
|
||||
AgentFn: api.agent,
|
||||
ConnectionLogger: opts.ConnectionLogger,
|
||||
Database: opts.Database,
|
||||
Workspace: api.cachedWorkspaceFields,
|
||||
Log: opts.Log,
|
||||
AgentFn: api.agent,
|
||||
ConnectionLogger: opts.ConnectionLogger,
|
||||
TailnetCoordinator: opts.TailnetCoordinator,
|
||||
Database: opts.Database,
|
||||
Workspace: api.cachedWorkspaceFields,
|
||||
Log: opts.Log,
|
||||
PublishWorkspaceUpdateFn: api.publishWorkspaceUpdate,
|
||||
}
|
||||
|
||||
api.DRPCService = &tailnet.DRPCService{
|
||||
|
||||
@@ -3,6 +3,8 @@ package agentapi
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -15,14 +17,18 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
)
|
||||
|
||||
type ConnLogAPI struct {
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
ConnectionLogger *atomic.Pointer[connectionlog.ConnectionLogger]
|
||||
Workspace *CachedWorkspaceFields
|
||||
Database database.Store
|
||||
Log slog.Logger
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
ConnectionLogger *atomic.Pointer[connectionlog.ConnectionLogger]
|
||||
TailnetCoordinator *atomic.Pointer[tailnet.Coordinator]
|
||||
Workspace *CachedWorkspaceFields
|
||||
Database database.Store
|
||||
Log slog.Logger
|
||||
PublishWorkspaceUpdateFn func(context.Context, *database.WorkspaceAgent, wspubsub.WorkspaceEventKind) error
|
||||
}
|
||||
|
||||
func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) {
|
||||
@@ -88,6 +94,35 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor
|
||||
}
|
||||
logIP := database.ParseIP(logIPRaw) // will return null if invalid
|
||||
|
||||
// At connect time, look up the tailnet peer to capture the
|
||||
// client hostname and description for session grouping later.
|
||||
var clientHostname, shortDescription sql.NullString
|
||||
if action == database.ConnectionStatusConnected && a.TailnetCoordinator != nil {
|
||||
if coord := a.TailnetCoordinator.Load(); coord != nil {
|
||||
for _, peer := range (*coord).TunnelPeers(workspaceAgent.ID) {
|
||||
if peer.Node != nil {
|
||||
// Match peer by checking if any of its addresses
|
||||
// match the connection IP.
|
||||
for _, addr := range peer.Node.Addresses {
|
||||
prefix, err := netip.ParsePrefix(addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if logIP.Valid && prefix.Addr().String() == logIP.IPNet.IP.String() {
|
||||
if peer.Node.Hostname != "" {
|
||||
clientHostname = sql.NullString{String: peer.Node.Hostname, Valid: true}
|
||||
}
|
||||
if peer.Node.ShortDescription != "" {
|
||||
shortDescription = sql.NullString{String: peer.Node.ShortDescription, Valid: true}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reason := req.GetConnection().GetReason()
|
||||
connLogger := *a.ConnectionLogger.Load()
|
||||
err = connLogger.Upsert(ctx, database.UpsertConnectionLogParams{
|
||||
@@ -98,6 +133,7 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: workspaceAgent.Name,
|
||||
AgentID: uuid.NullUUID{UUID: workspaceAgent.ID, Valid: true},
|
||||
Type: connectionType,
|
||||
Code: code,
|
||||
Ip: logIP,
|
||||
@@ -109,6 +145,7 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor
|
||||
String: reason,
|
||||
Valid: reason != "",
|
||||
},
|
||||
SessionID: uuid.NullUUID{},
|
||||
// We supply the action:
|
||||
// - So the DB can handle duplicate connections or disconnections properly.
|
||||
// - To make it clear whether this is a connection or disconnection
|
||||
@@ -121,13 +158,100 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor
|
||||
Valid: false,
|
||||
},
|
||||
// N/A
|
||||
UserAgent: sql.NullString{},
|
||||
// N/A
|
||||
SlugOrPort: sql.NullString{},
|
||||
UserAgent: sql.NullString{},
|
||||
ClientHostname: clientHostname,
|
||||
ShortDescription: shortDescription,
|
||||
SlugOrPort: sql.NullString{
|
||||
String: req.GetConnection().GetSlugOrPort(),
|
||||
Valid: req.GetConnection().GetSlugOrPort() != "",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("export connection log: %w", err)
|
||||
}
|
||||
|
||||
// At disconnect time, find or create a session for this connection.
|
||||
// This groups related connection logs into workspace sessions.
|
||||
if action == database.ConnectionStatusDisconnected {
|
||||
a.assignSessionForDisconnect(ctx, connectionID, ws, workspaceAgent, req)
|
||||
}
|
||||
|
||||
if a.PublishWorkspaceUpdateFn != nil {
|
||||
if err := a.PublishWorkspaceUpdateFn(ctx, &workspaceAgent, wspubsub.WorkspaceEventKindConnectionLogUpdate); err != nil {
|
||||
a.Log.Warn(ctx, "failed to publish connection log update", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
// assignSessionForDisconnect looks up the existing connection log for this
|
||||
// connection ID and finds or creates a session to group it with.
|
||||
func (a *ConnLogAPI) assignSessionForDisconnect(
|
||||
ctx context.Context,
|
||||
connectionID uuid.UUID,
|
||||
ws database.WorkspaceIdentity,
|
||||
workspaceAgent database.WorkspaceAgent,
|
||||
req *agentproto.ReportConnectionRequest,
|
||||
) {
|
||||
//nolint:gocritic // The agent context doesn't have connection_log
|
||||
// permissions. Session creation is authorized by the workspace
|
||||
// access already validated in ReportConnection.
|
||||
ctx = dbauthz.AsConnectionLogger(ctx)
|
||||
|
||||
existingLog, err := a.Database.GetConnectionLogByConnectionID(ctx, database.GetConnectionLogByConnectionIDParams{
|
||||
ConnectionID: uuid.NullUUID{UUID: connectionID, Valid: true},
|
||||
WorkspaceID: ws.ID,
|
||||
AgentName: workspaceAgent.Name,
|
||||
})
|
||||
if err != nil {
|
||||
a.Log.Warn(ctx, "failed to look up connection log for session assignment",
|
||||
slog.Error(err),
|
||||
slog.F("connection_id", connectionID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
sessionIDRaw, err := a.Database.FindOrCreateSessionForDisconnect(ctx, database.FindOrCreateSessionForDisconnectParams{
|
||||
WorkspaceID: ws.ID.String(),
|
||||
Ip: existingLog.Ip,
|
||||
ClientHostname: existingLog.ClientHostname,
|
||||
ShortDescription: existingLog.ShortDescription,
|
||||
ConnectTime: existingLog.ConnectTime,
|
||||
DisconnectTime: req.GetConnection().GetTimestamp().AsTime(),
|
||||
AgentID: uuid.NullUUID{UUID: workspaceAgent.ID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
a.Log.Warn(ctx, "failed to find or create session for disconnect",
|
||||
slog.Error(err),
|
||||
slog.F("connection_id", connectionID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// The query uses COALESCE which returns a generic type. The
|
||||
// database/sql driver may return the UUID as a string, []byte,
|
||||
// or [16]byte rather than uuid.UUID, so we parse it.
|
||||
sessionID, parseErr := uuid.Parse(fmt.Sprintf("%s", sessionIDRaw))
|
||||
if parseErr != nil {
|
||||
a.Log.Warn(ctx, "failed to parse session ID from FindOrCreateSessionForDisconnect",
|
||||
slog.Error(parseErr),
|
||||
slog.F("connection_id", connectionID),
|
||||
slog.F("session_id_raw", sessionIDRaw),
|
||||
slog.F("session_id_type", fmt.Sprintf("%T", sessionIDRaw)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Link the connection log to its session so that
|
||||
// CloseConnectionLogsAndCreateSessions skips it.
|
||||
if err := a.Database.UpdateConnectionLogSessionID(ctx, database.UpdateConnectionLogSessionIDParams{
|
||||
ID: existingLog.ID,
|
||||
SessionID: uuid.NullUUID{UUID: sessionID, Valid: true},
|
||||
}); err != nil {
|
||||
a.Log.Warn(ctx, "failed to update connection log session ID",
|
||||
slog.Error(err),
|
||||
slog.F("connection_id", connectionID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||||
)
|
||||
|
||||
func TestConnectionLog(t *testing.T) {
|
||||
@@ -41,14 +42,15 @@ func TestConnectionLog(t *testing.T) {
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id uuid.UUID
|
||||
action *agentproto.Connection_Action
|
||||
typ *agentproto.Connection_Type
|
||||
time time.Time
|
||||
ip string
|
||||
status int32
|
||||
reason string
|
||||
name string
|
||||
id uuid.UUID
|
||||
action *agentproto.Connection_Action
|
||||
typ *agentproto.Connection_Type
|
||||
time time.Time
|
||||
ip string
|
||||
status int32
|
||||
reason string
|
||||
slugOrPort string
|
||||
}{
|
||||
{
|
||||
name: "SSH Connect",
|
||||
@@ -84,6 +86,34 @@ func TestConnectionLog(t *testing.T) {
|
||||
typ: agentproto.Connection_RECONNECTING_PTY.Enum(),
|
||||
time: dbtime.Now(),
|
||||
},
|
||||
{
|
||||
name: "Port Forwarding Connect",
|
||||
id: uuid.New(),
|
||||
action: agentproto.Connection_CONNECT.Enum(),
|
||||
typ: agentproto.Connection_PORT_FORWARDING.Enum(),
|
||||
time: dbtime.Now(),
|
||||
ip: "192.168.1.1",
|
||||
slugOrPort: "8080",
|
||||
},
|
||||
{
|
||||
name: "Port Forwarding Disconnect",
|
||||
id: uuid.New(),
|
||||
action: agentproto.Connection_DISCONNECT.Enum(),
|
||||
typ: agentproto.Connection_PORT_FORWARDING.Enum(),
|
||||
time: dbtime.Now(),
|
||||
ip: "192.168.1.1",
|
||||
status: 200,
|
||||
slugOrPort: "8080",
|
||||
},
|
||||
{
|
||||
name: "Workspace App Connect",
|
||||
id: uuid.New(),
|
||||
action: agentproto.Connection_CONNECT.Enum(),
|
||||
typ: agentproto.Connection_WORKSPACE_APP.Enum(),
|
||||
time: dbtime.Now(),
|
||||
ip: "10.0.0.1",
|
||||
slugOrPort: "my-app",
|
||||
},
|
||||
{
|
||||
name: "SSH Disconnect",
|
||||
id: uuid.New(),
|
||||
@@ -110,6 +140,10 @@ func TestConnectionLog(t *testing.T) {
|
||||
|
||||
mDB := dbmock.NewMockStore(gomock.NewController(t))
|
||||
mDB.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil)
|
||||
// Disconnect actions trigger session assignment which calls
|
||||
// GetConnectionLogByConnectionID and FindOrCreateSessionForDisconnect.
|
||||
mDB.EXPECT().GetConnectionLogByConnectionID(gomock.Any(), gomock.Any()).Return(database.ConnectionLog{}, nil).AnyTimes()
|
||||
mDB.EXPECT().FindOrCreateSessionForDisconnect(gomock.Any(), gomock.Any()).Return(database.WorkspaceSession{}, nil).AnyTimes()
|
||||
|
||||
api := &agentapi.ConnLogAPI{
|
||||
ConnectionLogger: asAtomicPointer[connectionlog.ConnectionLogger](connLogger),
|
||||
@@ -128,6 +162,7 @@ func TestConnectionLog(t *testing.T) {
|
||||
Ip: tt.ip,
|
||||
StatusCode: tt.status,
|
||||
Reason: &tt.reason,
|
||||
SlugOrPort: &tt.slugOrPort,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -144,6 +179,7 @@ func TestConnectionLog(t *testing.T) {
|
||||
WorkspaceID: workspace.ID,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agent.Name,
|
||||
AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
|
||||
UserID: uuid.NullUUID{
|
||||
UUID: uuid.Nil,
|
||||
Valid: false,
|
||||
@@ -164,11 +200,72 @@ func TestConnectionLog(t *testing.T) {
|
||||
UUID: tt.id,
|
||||
Valid: tt.id != uuid.Nil,
|
||||
},
|
||||
SlugOrPort: sql.NullString{
|
||||
String: tt.slugOrPort,
|
||||
Valid: tt.slugOrPort != "",
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionLogPublishesWorkspaceUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
owner = database.User{ID: uuid.New(), Username: "cool-user"}
|
||||
workspace = database.Workspace{
|
||||
ID: uuid.New(),
|
||||
OrganizationID: uuid.New(),
|
||||
OwnerID: owner.ID,
|
||||
Name: "cool-workspace",
|
||||
}
|
||||
agent = database.WorkspaceAgent{ID: uuid.New()}
|
||||
)
|
||||
|
||||
connLogger := connectionlog.NewFake()
|
||||
|
||||
mDB := dbmock.NewMockStore(gomock.NewController(t))
|
||||
mDB.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil)
|
||||
|
||||
var (
|
||||
called int
|
||||
gotKind wspubsub.WorkspaceEventKind
|
||||
gotAgent uuid.UUID
|
||||
)
|
||||
|
||||
api := &agentapi.ConnLogAPI{
|
||||
ConnectionLogger: asAtomicPointer[connectionlog.ConnectionLogger](connLogger),
|
||||
Database: mDB,
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
PublishWorkspaceUpdateFn: func(ctx context.Context, agent *database.WorkspaceAgent, kind wspubsub.WorkspaceEventKind) error {
|
||||
called++
|
||||
gotKind = kind
|
||||
gotAgent = agent.ID
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
_, err := api.ReportConnection(context.Background(), &agentproto.ReportConnectionRequest{
|
||||
Connection: &agentproto.Connection{
|
||||
Id: id[:],
|
||||
Action: agentproto.Connection_CONNECT,
|
||||
Type: agentproto.Connection_SSH,
|
||||
Timestamp: timestamppb.New(dbtime.Now()),
|
||||
Ip: "127.0.0.1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, called)
|
||||
require.Equal(t, wspubsub.WorkspaceEventKindConnectionLogUpdate, gotKind)
|
||||
require.Equal(t, agent.ID, gotAgent)
|
||||
}
|
||||
|
||||
func agentProtoConnectionTypeToConnectionLog(t *testing.T, typ agentproto.Connection_Type) database.ConnectionType {
|
||||
a, err := db2sdk.ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ)
|
||||
require.NoError(t, err)
|
||||
|
||||
Generated
+889
-2
@@ -499,6 +499,92 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/connectionlog/diagnostics/{username}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Get user diagnostic report",
|
||||
"operationId": "get-user-diagnostic-report",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Hours to look back (default 72, max 168)",
|
||||
"name": "hours",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UserDiagnosticResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/connectionlog/sessions": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Get global workspace sessions",
|
||||
"operationId": "get-global-workspace-sessions",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page limit",
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page offset",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.GlobalWorkspaceSessionsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/csp/reports": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -11675,6 +11761,53 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{workspace}/sessions": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Workspaces"
|
||||
],
|
||||
"summary": "Get workspace sessions",
|
||||
"operationId": "get-workspace-sessions",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Workspace ID",
|
||||
"name": "workspace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page limit",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page offset",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSessionsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{workspace}/timings": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -13463,6 +13596,21 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ConnectionDiagnosticSeverity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"info",
|
||||
"warning",
|
||||
"error",
|
||||
"critical"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ConnectionDiagnosticSeverityInfo",
|
||||
"ConnectionDiagnosticSeverityWarning",
|
||||
"ConnectionDiagnosticSeverityError",
|
||||
"ConnectionDiagnosticSeverityCritical"
|
||||
]
|
||||
},
|
||||
"codersdk.ConnectionLatency": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13598,7 +13746,8 @@ const docTemplate = `{
|
||||
"jetbrains",
|
||||
"reconnecting_pty",
|
||||
"workspace_app",
|
||||
"port_forwarding"
|
||||
"port_forwarding",
|
||||
"system"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ConnectionTypeSSH",
|
||||
@@ -13606,7 +13755,8 @@ const docTemplate = `{
|
||||
"ConnectionTypeJetBrains",
|
||||
"ConnectionTypeReconnectingPTY",
|
||||
"ConnectionTypeWorkspaceApp",
|
||||
"ConnectionTypePortForwarding"
|
||||
"ConnectionTypePortForwarding",
|
||||
"ConnectionTypeSystem"
|
||||
]
|
||||
},
|
||||
"codersdk.ConvertLoginRequest": {
|
||||
@@ -14754,6 +14904,74 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticConnection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"agent_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"client_hostname": {
|
||||
"type": "string"
|
||||
},
|
||||
"detail": {
|
||||
"type": "string"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
"home_derp": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticHomeDERP"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"latency_ms": {
|
||||
"type": "number"
|
||||
},
|
||||
"p2p": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"short_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionType"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"workspace_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticDurationRange": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"min_seconds": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticExtra": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14762,6 +14980,246 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticHealth": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"healthy",
|
||||
"degraded",
|
||||
"unhealthy",
|
||||
"inactive"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"DiagnosticHealthHealthy",
|
||||
"DiagnosticHealthDegraded",
|
||||
"DiagnosticHealthUnhealthy",
|
||||
"DiagnosticHealthInactive"
|
||||
]
|
||||
},
|
||||
"codersdk.DiagnosticHomeDERP": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticNetworkSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avg_latency_ms": {
|
||||
"type": "number"
|
||||
},
|
||||
"derp_connections": {
|
||||
"type": "integer"
|
||||
},
|
||||
"p2p_connections": {
|
||||
"type": "integer"
|
||||
},
|
||||
"p95_latency_ms": {
|
||||
"type": "number"
|
||||
},
|
||||
"primary_derp_region": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticPattern": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"affected_sessions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"commonalities": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticPatternCommonality"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"recommendation": {
|
||||
"type": "string"
|
||||
},
|
||||
"severity": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionDiagnosticSeverity"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"total_sessions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticPatternType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticPatternCommonality": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_descriptions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"connection_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"disconnect_reasons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"duration_range": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticDurationRange"
|
||||
},
|
||||
"time_of_day_range": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticPatternType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"device_sleep",
|
||||
"workspace_autostart",
|
||||
"network_policy",
|
||||
"agent_crash",
|
||||
"latency_degradation",
|
||||
"derp_fallback",
|
||||
"clean_usage",
|
||||
"unknown_drops"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"DiagnosticPatternDeviceSleep",
|
||||
"DiagnosticPatternWorkspaceAutostart",
|
||||
"DiagnosticPatternNetworkPolicy",
|
||||
"DiagnosticPatternAgentCrash",
|
||||
"DiagnosticPatternLatencyDegradation",
|
||||
"DiagnosticPatternDERPFallback",
|
||||
"DiagnosticPatternCleanUsage",
|
||||
"DiagnosticPatternUnknownDrops"
|
||||
]
|
||||
},
|
||||
"codersdk.DiagnosticSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"client_hostname": {
|
||||
"type": "string"
|
||||
},
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticSessionConn"
|
||||
}
|
||||
},
|
||||
"disconnect_reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticSessionNetwork"
|
||||
},
|
||||
"short_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
},
|
||||
"timeline": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticTimelineEvent"
|
||||
}
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"workspace_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticSessionConn": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connected_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"detail": {
|
||||
"type": "string"
|
||||
},
|
||||
"disconnected_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"exit_code": {
|
||||
"type": "integer"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticSessionNetwork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avg_latency_ms": {
|
||||
"type": "number"
|
||||
},
|
||||
"home_derp": {
|
||||
"type": "string"
|
||||
},
|
||||
"p2p": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticSeverityString": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -14773,6 +15231,193 @@ const docTemplate = `{
|
||||
"DiagnosticSeverityWarning"
|
||||
]
|
||||
},
|
||||
"codersdk.DiagnosticStatusBreakdown": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clean": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lost": {
|
||||
"type": "integer"
|
||||
},
|
||||
"ongoing": {
|
||||
"type": "integer"
|
||||
},
|
||||
"workspace_deleted": {
|
||||
"type": "integer"
|
||||
},
|
||||
"workspace_stopped": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_connections": {
|
||||
"type": "integer"
|
||||
},
|
||||
"by_status": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticStatusBreakdown"
|
||||
},
|
||||
"by_type": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"headline": {
|
||||
"type": "string"
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticNetworkSummary"
|
||||
},
|
||||
"total_connections": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_sessions": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticTimeWindow": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"hours": {
|
||||
"type": "integer"
|
||||
},
|
||||
"start": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticTimelineEvent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticTimelineEventKind"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"severity": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionDiagnosticSeverity"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticTimelineEventKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tunnel_created",
|
||||
"tunnel_removed",
|
||||
"node_update",
|
||||
"peer_lost",
|
||||
"peer_recovered",
|
||||
"connection_opened",
|
||||
"connection_closed",
|
||||
"derp_fallback",
|
||||
"p2p_established",
|
||||
"latency_spike",
|
||||
"workspace_state_change"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"DiagnosticTimelineEventTunnelCreated",
|
||||
"DiagnosticTimelineEventTunnelRemoved",
|
||||
"DiagnosticTimelineEventNodeUpdate",
|
||||
"DiagnosticTimelineEventPeerLost",
|
||||
"DiagnosticTimelineEventPeerRecovered",
|
||||
"DiagnosticTimelineEventConnectionOpened",
|
||||
"DiagnosticTimelineEventConnectionClosed",
|
||||
"DiagnosticTimelineEventDERPFallback",
|
||||
"DiagnosticTimelineEventP2PEstablished",
|
||||
"DiagnosticTimelineEventLatencySpike",
|
||||
"DiagnosticTimelineEventWorkspaceStateChange"
|
||||
]
|
||||
},
|
||||
"codersdk.DiagnosticUser": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"last_seen_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticWorkspace": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"health": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticHealth"
|
||||
},
|
||||
"health_reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_username": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticSession"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"template_display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"template_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DisplayApp": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -15269,6 +15914,65 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.GlobalWorkspaceSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_hostname": {
|
||||
"type": "string"
|
||||
},
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnection"
|
||||
}
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"id": {
|
||||
"description": "nil for live sessions",
|
||||
"type": "string"
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"short_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"workspace_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_owner_username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.GlobalWorkspaceSessionsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.GlobalWorkspaceSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Group": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20199,6 +20903,42 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserDiagnosticResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current_connections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticConnection"
|
||||
}
|
||||
},
|
||||
"generated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"patterns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticPattern"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticSummary"
|
||||
},
|
||||
"time_window": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticTimeWindow"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticUser"
|
||||
},
|
||||
"workspaces": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticWorkspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserLatency": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20699,6 +21439,12 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentScript"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSession"
|
||||
}
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -21509,6 +22255,83 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceConnection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_hostname": {
|
||||
"description": "ClientHostname is the hostname of the client that connected to the agent. Self-reported by the client.",
|
||||
"type": "string"
|
||||
},
|
||||
"connected_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"detail": {
|
||||
"description": "Detail is the app slug or port number for workspace_app and port_forwarding connections.",
|
||||
"type": "string"
|
||||
},
|
||||
"disconnect_reason": {
|
||||
"description": "DisconnectReason is the reason the connection was closed.",
|
||||
"type": "string"
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"exit_code": {
|
||||
"description": "ExitCode is the exit code of the SSH session.",
|
||||
"type": "integer"
|
||||
},
|
||||
"home_derp": {
|
||||
"description": "HomeDERP is the DERP region metadata for the agent's home relay.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionHomeDERP"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"latency_ms": {
|
||||
"description": "LatencyMS is the most recent round-trip latency in\nmilliseconds. Uses P2P latency when direct, DERP otherwise.",
|
||||
"type": "number"
|
||||
},
|
||||
"p2p": {
|
||||
"description": "P2P indicates a direct peer-to-peer connection (true) or\nDERP relay (false). Nil if telemetry unavailable.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"short_description": {
|
||||
"description": "ShortDescription is the human-readable short description of the connection. Self-reported by the client.",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionType"
|
||||
},
|
||||
"user_agent": {
|
||||
"description": "UserAgent is the HTTP user agent string from web connections.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceConnectionHomeDERP": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceConnectionLatencyMS": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -21522,6 +22345,21 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceConnectionStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ongoing",
|
||||
"control_lost",
|
||||
"client_disconnected",
|
||||
"clean_disconnected"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ConnectionStatusOngoing",
|
||||
"ConnectionStatusControlLost",
|
||||
"ConnectionStatusClientDisconnected",
|
||||
"ConnectionStatusCleanDisconnected"
|
||||
]
|
||||
},
|
||||
"codersdk.WorkspaceDeploymentStats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -21796,6 +22634,55 @@ const docTemplate = `{
|
||||
"WorkspaceRoleDeleted"
|
||||
]
|
||||
},
|
||||
"codersdk.WorkspaceSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_hostname": {
|
||||
"type": "string"
|
||||
},
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnection"
|
||||
}
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"id": {
|
||||
"description": "nil for live sessions",
|
||||
"type": "string"
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"short_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceSessionsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceSharingSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+867
-2
@@ -428,6 +428,84 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/connectionlog/diagnostics/{username}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Get user diagnostic report",
|
||||
"operationId": "get-user-diagnostic-report",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Hours to look back (default 72, max 168)",
|
||||
"name": "hours",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UserDiagnosticResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/connectionlog/sessions": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Get global workspace sessions",
|
||||
"operationId": "get-global-workspace-sessions",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page limit",
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page offset",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.GlobalWorkspaceSessionsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/csp/reports": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -10337,6 +10415,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{workspace}/sessions": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Workspaces"],
|
||||
"summary": "Get workspace sessions",
|
||||
"operationId": "get-workspace-sessions",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Workspace ID",
|
||||
"name": "workspace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page limit",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page offset",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSessionsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{workspace}/timings": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -12058,6 +12179,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ConnectionDiagnosticSeverity": {
|
||||
"type": "string",
|
||||
"enum": ["info", "warning", "error", "critical"],
|
||||
"x-enum-varnames": [
|
||||
"ConnectionDiagnosticSeverityInfo",
|
||||
"ConnectionDiagnosticSeverityWarning",
|
||||
"ConnectionDiagnosticSeverityError",
|
||||
"ConnectionDiagnosticSeverityCritical"
|
||||
]
|
||||
},
|
||||
"codersdk.ConnectionLatency": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -12193,7 +12324,8 @@
|
||||
"jetbrains",
|
||||
"reconnecting_pty",
|
||||
"workspace_app",
|
||||
"port_forwarding"
|
||||
"port_forwarding",
|
||||
"system"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ConnectionTypeSSH",
|
||||
@@ -12201,7 +12333,8 @@
|
||||
"ConnectionTypeJetBrains",
|
||||
"ConnectionTypeReconnectingPTY",
|
||||
"ConnectionTypeWorkspaceApp",
|
||||
"ConnectionTypePortForwarding"
|
||||
"ConnectionTypePortForwarding",
|
||||
"ConnectionTypeSystem"
|
||||
]
|
||||
},
|
||||
"codersdk.ConvertLoginRequest": {
|
||||
@@ -13302,6 +13435,74 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticConnection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"agent_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"client_hostname": {
|
||||
"type": "string"
|
||||
},
|
||||
"detail": {
|
||||
"type": "string"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
"home_derp": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticHomeDERP"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"latency_ms": {
|
||||
"type": "number"
|
||||
},
|
||||
"p2p": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"short_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionType"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"workspace_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticDurationRange": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"min_seconds": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticExtra": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13310,6 +13511,241 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticHealth": {
|
||||
"type": "string",
|
||||
"enum": ["healthy", "degraded", "unhealthy", "inactive"],
|
||||
"x-enum-varnames": [
|
||||
"DiagnosticHealthHealthy",
|
||||
"DiagnosticHealthDegraded",
|
||||
"DiagnosticHealthUnhealthy",
|
||||
"DiagnosticHealthInactive"
|
||||
]
|
||||
},
|
||||
"codersdk.DiagnosticHomeDERP": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticNetworkSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avg_latency_ms": {
|
||||
"type": "number"
|
||||
},
|
||||
"derp_connections": {
|
||||
"type": "integer"
|
||||
},
|
||||
"p2p_connections": {
|
||||
"type": "integer"
|
||||
},
|
||||
"p95_latency_ms": {
|
||||
"type": "number"
|
||||
},
|
||||
"primary_derp_region": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticPattern": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"affected_sessions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"commonalities": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticPatternCommonality"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"recommendation": {
|
||||
"type": "string"
|
||||
},
|
||||
"severity": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionDiagnosticSeverity"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"total_sessions": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticPatternType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticPatternCommonality": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_descriptions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"connection_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"disconnect_reasons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"duration_range": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticDurationRange"
|
||||
},
|
||||
"time_of_day_range": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticPatternType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"device_sleep",
|
||||
"workspace_autostart",
|
||||
"network_policy",
|
||||
"agent_crash",
|
||||
"latency_degradation",
|
||||
"derp_fallback",
|
||||
"clean_usage",
|
||||
"unknown_drops"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"DiagnosticPatternDeviceSleep",
|
||||
"DiagnosticPatternWorkspaceAutostart",
|
||||
"DiagnosticPatternNetworkPolicy",
|
||||
"DiagnosticPatternAgentCrash",
|
||||
"DiagnosticPatternLatencyDegradation",
|
||||
"DiagnosticPatternDERPFallback",
|
||||
"DiagnosticPatternCleanUsage",
|
||||
"DiagnosticPatternUnknownDrops"
|
||||
]
|
||||
},
|
||||
"codersdk.DiagnosticSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"client_hostname": {
|
||||
"type": "string"
|
||||
},
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticSessionConn"
|
||||
}
|
||||
},
|
||||
"disconnect_reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticSessionNetwork"
|
||||
},
|
||||
"short_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
},
|
||||
"timeline": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticTimelineEvent"
|
||||
}
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"workspace_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticSessionConn": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connected_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"detail": {
|
||||
"type": "string"
|
||||
},
|
||||
"disconnected_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"exit_code": {
|
||||
"type": "integer"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticSessionNetwork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avg_latency_ms": {
|
||||
"type": "number"
|
||||
},
|
||||
"home_derp": {
|
||||
"type": "string"
|
||||
},
|
||||
"p2p": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticSeverityString": {
|
||||
"type": "string",
|
||||
"enum": ["error", "warning"],
|
||||
@@ -13318,6 +13754,193 @@
|
||||
"DiagnosticSeverityWarning"
|
||||
]
|
||||
},
|
||||
"codersdk.DiagnosticStatusBreakdown": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clean": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lost": {
|
||||
"type": "integer"
|
||||
},
|
||||
"ongoing": {
|
||||
"type": "integer"
|
||||
},
|
||||
"workspace_deleted": {
|
||||
"type": "integer"
|
||||
},
|
||||
"workspace_stopped": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_connections": {
|
||||
"type": "integer"
|
||||
},
|
||||
"by_status": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticStatusBreakdown"
|
||||
},
|
||||
"by_type": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"headline": {
|
||||
"type": "string"
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticNetworkSummary"
|
||||
},
|
||||
"total_connections": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_sessions": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticTimeWindow": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"hours": {
|
||||
"type": "integer"
|
||||
},
|
||||
"start": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticTimelineEvent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticTimelineEventKind"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"severity": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionDiagnosticSeverity"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticTimelineEventKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tunnel_created",
|
||||
"tunnel_removed",
|
||||
"node_update",
|
||||
"peer_lost",
|
||||
"peer_recovered",
|
||||
"connection_opened",
|
||||
"connection_closed",
|
||||
"derp_fallback",
|
||||
"p2p_established",
|
||||
"latency_spike",
|
||||
"workspace_state_change"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"DiagnosticTimelineEventTunnelCreated",
|
||||
"DiagnosticTimelineEventTunnelRemoved",
|
||||
"DiagnosticTimelineEventNodeUpdate",
|
||||
"DiagnosticTimelineEventPeerLost",
|
||||
"DiagnosticTimelineEventPeerRecovered",
|
||||
"DiagnosticTimelineEventConnectionOpened",
|
||||
"DiagnosticTimelineEventConnectionClosed",
|
||||
"DiagnosticTimelineEventDERPFallback",
|
||||
"DiagnosticTimelineEventP2PEstablished",
|
||||
"DiagnosticTimelineEventLatencySpike",
|
||||
"DiagnosticTimelineEventWorkspaceStateChange"
|
||||
]
|
||||
},
|
||||
"codersdk.DiagnosticUser": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"last_seen_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DiagnosticWorkspace": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"health": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticHealth"
|
||||
},
|
||||
"health_reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_username": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticSession"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"template_display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"template_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DisplayApp": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -13810,6 +14433,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.GlobalWorkspaceSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_hostname": {
|
||||
"type": "string"
|
||||
},
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnection"
|
||||
}
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"id": {
|
||||
"description": "nil for live sessions",
|
||||
"type": "string"
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"short_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"workspace_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_owner_username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.GlobalWorkspaceSessionsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.GlobalWorkspaceSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Group": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18518,6 +19200,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserDiagnosticResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current_connections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticConnection"
|
||||
}
|
||||
},
|
||||
"generated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"patterns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticPattern"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticSummary"
|
||||
},
|
||||
"time_window": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticTimeWindow"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticUser"
|
||||
},
|
||||
"workspaces": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DiagnosticWorkspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserLatency": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19003,6 +19721,12 @@
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentScript"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSession"
|
||||
}
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -19758,6 +20482,83 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceConnection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_hostname": {
|
||||
"description": "ClientHostname is the hostname of the client that connected to the agent. Self-reported by the client.",
|
||||
"type": "string"
|
||||
},
|
||||
"connected_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"detail": {
|
||||
"description": "Detail is the app slug or port number for workspace_app and port_forwarding connections.",
|
||||
"type": "string"
|
||||
},
|
||||
"disconnect_reason": {
|
||||
"description": "DisconnectReason is the reason the connection was closed.",
|
||||
"type": "string"
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"exit_code": {
|
||||
"description": "ExitCode is the exit code of the SSH session.",
|
||||
"type": "integer"
|
||||
},
|
||||
"home_derp": {
|
||||
"description": "HomeDERP is the DERP region metadata for the agent's home relay.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionHomeDERP"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"latency_ms": {
|
||||
"description": "LatencyMS is the most recent round-trip latency in\nmilliseconds. Uses P2P latency when direct, DERP otherwise.",
|
||||
"type": "number"
|
||||
},
|
||||
"p2p": {
|
||||
"description": "P2P indicates a direct peer-to-peer connection (true) or\nDERP relay (false). Nil if telemetry unavailable.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"short_description": {
|
||||
"description": "ShortDescription is the human-readable short description of the connection. Self-reported by the client.",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionType"
|
||||
},
|
||||
"user_agent": {
|
||||
"description": "UserAgent is the HTTP user agent string from web connections.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceConnectionHomeDERP": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceConnectionLatencyMS": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19771,6 +20572,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceConnectionStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ongoing",
|
||||
"control_lost",
|
||||
"client_disconnected",
|
||||
"clean_disconnected"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ConnectionStatusOngoing",
|
||||
"ConnectionStatusControlLost",
|
||||
"ConnectionStatusClientDisconnected",
|
||||
"ConnectionStatusCleanDisconnected"
|
||||
]
|
||||
},
|
||||
"codersdk.WorkspaceDeploymentStats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20034,6 +20850,55 @@
|
||||
"WorkspaceRoleDeleted"
|
||||
]
|
||||
},
|
||||
"codersdk.WorkspaceSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_hostname": {
|
||||
"type": "string"
|
||||
},
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnection"
|
||||
}
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"id": {
|
||||
"description": "nil for live sessions",
|
||||
"type": "string"
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"short_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceConnectionStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceSessionsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceSharingSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+50
-9
@@ -90,6 +90,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/drpcsdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
@@ -98,6 +99,8 @@ import (
|
||||
"github.com/coder/coder/v2/provisionersdk"
|
||||
"github.com/coder/coder/v2/site"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/eventsink"
|
||||
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
@@ -414,7 +417,8 @@ func New(options *Options) *API {
|
||||
options.NetworkTelemetryBatchMaxSize = 1_000
|
||||
}
|
||||
if options.TailnetCoordinator == nil {
|
||||
options.TailnetCoordinator = tailnet.NewCoordinator(options.Logger)
|
||||
eventSink := eventsink.NewEventSink(context.Background(), options.Database, options.Logger)
|
||||
options.TailnetCoordinator = tailnet.NewCoordinator(options.Logger, eventSink)
|
||||
}
|
||||
if options.Auditor == nil {
|
||||
options.Auditor = audit.NewNop()
|
||||
@@ -734,20 +738,23 @@ func New(options *Options) *API {
|
||||
api.Auditor.Store(&options.Auditor)
|
||||
api.ConnectionLogger.Store(&options.ConnectionLogger)
|
||||
api.TailnetCoordinator.Store(&options.TailnetCoordinator)
|
||||
serverTailnetID := uuid.New()
|
||||
dialer := &InmemTailnetDialer{
|
||||
CoordPtr: &api.TailnetCoordinator,
|
||||
DERPFn: api.DERPMap,
|
||||
Logger: options.Logger,
|
||||
ClientID: uuid.New(),
|
||||
ClientID: serverTailnetID,
|
||||
DatabaseHealthCheck: api.Database,
|
||||
}
|
||||
stn, err := NewServerTailnet(api.ctx,
|
||||
options.Logger,
|
||||
options.DERPServer,
|
||||
serverTailnetID,
|
||||
dialer,
|
||||
options.DeploymentValues.DERP.Config.ForceWebSockets.Value(),
|
||||
options.DeploymentValues.DERP.Config.BlockDirect.Value(),
|
||||
api.TracerProvider,
|
||||
"Coder Server",
|
||||
)
|
||||
if err != nil {
|
||||
panic("failed to setup server tailnet: " + err.Error())
|
||||
@@ -763,17 +770,19 @@ func New(options *Options) *API {
|
||||
api.Options.NetworkTelemetryBatchMaxSize,
|
||||
api.handleNetworkTelemetry,
|
||||
)
|
||||
api.PeerNetworkTelemetryStore = NewPeerNetworkTelemetryStore()
|
||||
if options.CoordinatorResumeTokenProvider == nil {
|
||||
panic("CoordinatorResumeTokenProvider is nil")
|
||||
}
|
||||
api.TailnetClientService, err = tailnet.NewClientService(tailnet.ClientServiceOptions{
|
||||
Logger: api.Logger.Named("tailnetclient"),
|
||||
CoordPtr: &api.TailnetCoordinator,
|
||||
DERPMapUpdateFrequency: api.Options.DERPMapUpdateFrequency,
|
||||
DERPMapFn: api.DERPMap,
|
||||
NetworkTelemetryHandler: api.NetworkTelemetryBatcher.Handler,
|
||||
ResumeTokenProvider: api.Options.CoordinatorResumeTokenProvider,
|
||||
WorkspaceUpdatesProvider: api.UpdatesProvider,
|
||||
Logger: api.Logger.Named("tailnetclient"),
|
||||
CoordPtr: &api.TailnetCoordinator,
|
||||
DERPMapUpdateFrequency: api.Options.DERPMapUpdateFrequency,
|
||||
DERPMapFn: api.DERPMap,
|
||||
NetworkTelemetryHandler: api.NetworkTelemetryBatcher.Handler,
|
||||
IdentifiedTelemetryHandler: api.handleIdentifiedTelemetry,
|
||||
ResumeTokenProvider: api.Options.CoordinatorResumeTokenProvider,
|
||||
WorkspaceUpdatesProvider: api.UpdatesProvider,
|
||||
})
|
||||
if err != nil {
|
||||
api.Logger.Fatal(context.Background(), "failed to initialize tailnet client service", slog.Error(err))
|
||||
@@ -1517,6 +1526,7 @@ func New(options *Options) *API {
|
||||
r.Delete("/", api.deleteWorkspaceAgentPortShare)
|
||||
})
|
||||
r.Get("/timings", api.workspaceTimings)
|
||||
r.Get("/sessions", api.workspaceSessions)
|
||||
r.Route("/acl", func(r chi.Router) {
|
||||
r.Use(
|
||||
httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentWorkspaceSharing),
|
||||
@@ -1828,6 +1838,7 @@ type API struct {
|
||||
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
|
||||
TailnetCoordinator atomic.Pointer[tailnet.Coordinator]
|
||||
NetworkTelemetryBatcher *tailnet.NetworkTelemetryBatcher
|
||||
PeerNetworkTelemetryStore *PeerNetworkTelemetryStore
|
||||
TailnetClientService *tailnet.ClientService
|
||||
// WebpushDispatcher is a way to send notifications to users via Web Push.
|
||||
WebpushDispatcher webpush.Dispatcher
|
||||
@@ -1962,6 +1973,36 @@ func (api *API) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleIdentifiedTelemetry stores peer telemetry events and publishes a
|
||||
// workspace update so watch subscribers see fresh data.
|
||||
func (api *API) handleIdentifiedTelemetry(agentID, peerID uuid.UUID, events []*tailnetproto.TelemetryEvent) {
|
||||
if len(events) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
api.PeerNetworkTelemetryStore.Update(agentID, peerID, event)
|
||||
}
|
||||
|
||||
// Telemetry callback runs outside any user request, so we use a system
|
||||
// context to look up the workspace for the pubsub notification.
|
||||
ctx := dbauthz.AsSystemRestricted(context.Background()) //nolint:gocritic // Telemetry callback has no user context.
|
||||
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, agentID)
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "failed to resolve workspace for telemetry update",
|
||||
slog.F("agent_id", agentID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
|
||||
Kind: wspubsub.WorkspaceEventKindConnectionLogUpdate,
|
||||
WorkspaceID: workspace.ID,
|
||||
AgentID: &agentID,
|
||||
})
|
||||
}
|
||||
|
||||
func compressHandler(h http.Handler) http.Handler {
|
||||
level := 5
|
||||
if flag.Lookup("test.v") != nil {
|
||||
|
||||
@@ -82,6 +82,10 @@ func (m *FakeConnectionLogger) Contains(t testing.TB, expected database.UpsertCo
|
||||
t.Logf("connection log %d: expected AgentName %s, got %s", idx+1, expected.AgentName, cl.AgentName)
|
||||
continue
|
||||
}
|
||||
if expected.AgentID.Valid && cl.AgentID.UUID != expected.AgentID.UUID {
|
||||
t.Logf("connection log %d: expected AgentID %s, got %s", idx+1, expected.AgentID.UUID, cl.AgentID.UUID)
|
||||
continue
|
||||
}
|
||||
if expected.Type != "" && cl.Type != expected.Type {
|
||||
t.Logf("connection log %d: expected Type %s, got %s", idx+1, expected.Type, cl.Type)
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,938 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
)
|
||||
|
||||
func TestCloseOpenAgentConnectionLogsForWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
o := dbgen.Organization(t, db, database.Organization{})
|
||||
tpl := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: o.ID,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
|
||||
ws1 := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
ID: uuid.New(),
|
||||
OwnerID: u.ID,
|
||||
OrganizationID: o.ID,
|
||||
AutomaticUpdates: database.AutomaticUpdatesNever,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
ws2 := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
ID: uuid.New(),
|
||||
OwnerID: u.ID,
|
||||
OrganizationID: o.ID,
|
||||
AutomaticUpdates: database.AutomaticUpdatesNever,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
|
||||
ip := pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(127, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
// Simulate agent clock skew by using a connect time in the future.
|
||||
connectTime := dbtime.Now().Add(time.Hour)
|
||||
|
||||
sshLog1, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: connectTime,
|
||||
OrganizationID: ws1.OrganizationID,
|
||||
WorkspaceOwnerID: ws1.OwnerID,
|
||||
WorkspaceID: ws1.ID,
|
||||
WorkspaceName: ws1.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
appLog, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: dbtime.Now(),
|
||||
OrganizationID: ws1.OrganizationID,
|
||||
WorkspaceOwnerID: ws1.OwnerID,
|
||||
WorkspaceID: ws1.ID,
|
||||
WorkspaceName: ws1.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeWorkspaceApp,
|
||||
Ip: ip,
|
||||
UserAgent: sql.NullString{String: "test", Valid: true},
|
||||
UserID: uuid.NullUUID{UUID: ws1.OwnerID, Valid: true},
|
||||
SlugOrPort: sql.NullString{String: "app", Valid: true},
|
||||
Code: sql.NullInt32{Int32: 200, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sshLog2, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: dbtime.Now(),
|
||||
OrganizationID: ws2.OrganizationID,
|
||||
WorkspaceOwnerID: ws2.OwnerID,
|
||||
WorkspaceID: ws2.ID,
|
||||
WorkspaceName: ws2.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rowsClosed, err := db.CloseOpenAgentConnectionLogsForWorkspace(ctx, database.CloseOpenAgentConnectionLogsForWorkspaceParams{
|
||||
WorkspaceID: ws1.ID,
|
||||
ClosedAt: dbtime.Now(),
|
||||
Reason: "workspace stopped",
|
||||
Types: []database.ConnectionType{
|
||||
database.ConnectionTypeSsh,
|
||||
database.ConnectionTypeVscode,
|
||||
database.ConnectionTypeJetbrains,
|
||||
database.ConnectionTypeReconnectingPty,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, rowsClosed)
|
||||
|
||||
ws1Rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{WorkspaceID: ws1.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ws1Rows, 2)
|
||||
|
||||
for _, row := range ws1Rows {
|
||||
switch row.ConnectionLog.ID {
|
||||
case sshLog1.ID:
|
||||
updated := row.ConnectionLog
|
||||
require.True(t, updated.DisconnectTime.Valid)
|
||||
require.True(t, updated.DisconnectReason.Valid)
|
||||
require.Equal(t, "workspace stopped", updated.DisconnectReason.String)
|
||||
require.False(t, updated.DisconnectTime.Time.Before(updated.ConnectTime), "disconnect_time should never be before connect_time")
|
||||
case appLog.ID:
|
||||
notClosed := row.ConnectionLog
|
||||
require.False(t, notClosed.DisconnectTime.Valid)
|
||||
require.False(t, notClosed.DisconnectReason.Valid)
|
||||
default:
|
||||
t.Fatalf("unexpected connection log id: %s", row.ConnectionLog.ID)
|
||||
}
|
||||
}
|
||||
|
||||
ws2Rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{WorkspaceID: ws2.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ws2Rows, 1)
|
||||
require.Equal(t, sshLog2.ID, ws2Rows[0].ConnectionLog.ID)
|
||||
require.False(t, ws2Rows[0].ConnectionLog.DisconnectTime.Valid)
|
||||
}
|
||||
|
||||
// Regression test: CloseConnectionLogsAndCreateSessions must not fail
|
||||
// when connection_logs have NULL IPs (e.g., disconnect-only tunnel
|
||||
// events). NULL-IP logs should be closed but no session created for
|
||||
// them.
|
||||
func TestCloseConnectionLogsAndCreateSessions_NullIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
o := dbgen.Organization(t, db, database.Organization{})
|
||||
tpl := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: o.ID,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: u.ID,
|
||||
OrganizationID: o.ID,
|
||||
AutomaticUpdates: database.AutomaticUpdatesNever,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
|
||||
validIP := pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(10, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
}
|
||||
now := dbtime.Now()
|
||||
|
||||
// Connection with a valid IP.
|
||||
sshLog, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-30 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: validIP,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Connection with a NULL IP — simulates a disconnect-only tunnel
|
||||
// event where the source node info is unavailable.
|
||||
nullIPLog, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-25 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSystem,
|
||||
Ip: pqtype.Inet{Valid: false},
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// This previously failed with: "pq: null value in column ip of
|
||||
// relation workspace_sessions violates not-null constraint".
|
||||
closedAt := now.Add(-5 * time.Minute)
|
||||
_, err = db.CloseConnectionLogsAndCreateSessions(ctx, database.CloseConnectionLogsAndCreateSessionsParams{
|
||||
ClosedAt: sql.NullTime{Time: closedAt, Valid: true},
|
||||
Reason: sql.NullString{String: "workspace stopped", Valid: true},
|
||||
WorkspaceID: ws.ID,
|
||||
Types: []database.ConnectionType{
|
||||
database.ConnectionTypeSsh,
|
||||
database.ConnectionTypeSystem,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both logs were closed.
|
||||
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{
|
||||
WorkspaceID: ws.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 2)
|
||||
|
||||
for _, row := range rows {
|
||||
cl := row.ConnectionLog
|
||||
require.True(t, cl.DisconnectTime.Valid,
|
||||
"connection log %s (type=%s) should be closed", cl.ID, cl.Type)
|
||||
|
||||
switch cl.ID {
|
||||
case sshLog.ID:
|
||||
// Valid-IP log should have a session.
|
||||
require.True(t, cl.SessionID.Valid,
|
||||
"valid-IP log should be linked to a session")
|
||||
case nullIPLog.ID:
|
||||
// NULL-IP system connection overlaps with the SSH
|
||||
// session, so it gets attached to that session.
|
||||
require.True(t, cl.SessionID.Valid,
|
||||
"NULL-IP system log overlapping with SSH session should be linked to a session")
|
||||
default:
|
||||
t.Fatalf("unexpected connection log id: %s", cl.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test: CloseConnectionLogsAndCreateSessions must handle
|
||||
// connections that are already disconnected but have no session_id
|
||||
// (e.g., system/tunnel connections disconnected by dbsink). It must
|
||||
// also avoid creating duplicate sessions when assignSessionForDisconnect
|
||||
// has already created one for the same IP/time range.
|
||||
func TestCloseConnectionLogsAndCreateSessions_AlreadyDisconnectedGetsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
o := dbgen.Organization(t, db, database.Organization{})
|
||||
tpl := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: o.ID,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: u.ID,
|
||||
OrganizationID: o.ID,
|
||||
AutomaticUpdates: database.AutomaticUpdatesNever,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
|
||||
ip := pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(127, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
}
|
||||
now := dbtime.Now()
|
||||
|
||||
// A system connection that was already disconnected (by dbsink)
|
||||
// but has no session_id — dbsink doesn't assign sessions.
|
||||
sysConnID := uuid.New()
|
||||
_, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-10 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSystem,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: sysConnID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-5 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSystem,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: sysConnID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusDisconnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run CloseConnectionLogsAndCreateSessions (workspace stop).
|
||||
closedAt := now
|
||||
_, err = db.CloseConnectionLogsAndCreateSessions(ctx, database.CloseConnectionLogsAndCreateSessionsParams{
|
||||
ClosedAt: sql.NullTime{Time: closedAt, Valid: true},
|
||||
Reason: sql.NullString{String: "workspace stopped", Valid: true},
|
||||
WorkspaceID: ws.ID,
|
||||
Types: []database.ConnectionType{
|
||||
database.ConnectionTypeSsh,
|
||||
database.ConnectionTypeSystem,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The system connection should now have a session_id.
|
||||
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{
|
||||
WorkspaceID: ws.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
require.True(t, rows[0].ConnectionLog.SessionID.Valid,
|
||||
"already-disconnected system connection should be assigned to a session")
|
||||
}
|
||||
|
||||
// Regression test: when assignSessionForDisconnect has already
|
||||
// created a session for an SSH connection,
|
||||
// CloseConnectionLogsAndCreateSessions must reuse that session
|
||||
// instead of creating a duplicate.
|
||||
func TestCloseConnectionLogsAndCreateSessions_ReusesExistingSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
o := dbgen.Organization(t, db, database.Organization{})
|
||||
tpl := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: o.ID,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: u.ID,
|
||||
OrganizationID: o.ID,
|
||||
AutomaticUpdates: database.AutomaticUpdatesNever,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
|
||||
ip := pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(127, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
}
|
||||
now := dbtime.Now()
|
||||
|
||||
// Simulate an SSH connection where assignSessionForDisconnect
|
||||
// already created a session but the connection log's session_id
|
||||
// was set (the normal successful path).
|
||||
sshConnID := uuid.New()
|
||||
_, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-10 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: sshConnID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
sshLog, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-5 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: sshConnID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusDisconnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create the session that assignSessionForDisconnect would have
|
||||
// created, and link the connection log to it.
|
||||
existingSessionIDRaw, err := db.FindOrCreateSessionForDisconnect(ctx, database.FindOrCreateSessionForDisconnectParams{
|
||||
WorkspaceID: ws.ID.String(),
|
||||
Ip: ip,
|
||||
ConnectTime: sshLog.ConnectTime,
|
||||
DisconnectTime: sshLog.DisconnectTime.Time,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
existingSessionID, err := uuid.Parse(fmt.Sprintf("%s", existingSessionIDRaw))
|
||||
require.NoError(t, err)
|
||||
err = db.UpdateConnectionLogSessionID(ctx, database.UpdateConnectionLogSessionIDParams{
|
||||
ID: sshLog.ID,
|
||||
SessionID: uuid.NullUUID{UUID: existingSessionID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Also add a system connection (no session, already disconnected).
|
||||
sysConnID := uuid.New()
|
||||
_, err = db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-10 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSystem,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: sysConnID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-5 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSystem,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: sysConnID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusDisconnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run CloseConnectionLogsAndCreateSessions.
|
||||
closedAt := now
|
||||
_, err = db.CloseConnectionLogsAndCreateSessions(ctx, database.CloseConnectionLogsAndCreateSessionsParams{
|
||||
ClosedAt: sql.NullTime{Time: closedAt, Valid: true},
|
||||
Reason: sql.NullString{String: "workspace stopped", Valid: true},
|
||||
WorkspaceID: ws.ID,
|
||||
Types: []database.ConnectionType{
|
||||
database.ConnectionTypeSsh,
|
||||
database.ConnectionTypeSystem,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify: the system connection should be assigned to the
|
||||
// EXISTING session (reused), not a new one.
|
||||
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{
|
||||
WorkspaceID: ws.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 2)
|
||||
|
||||
for _, row := range rows {
|
||||
cl := row.ConnectionLog
|
||||
require.True(t, cl.SessionID.Valid,
|
||||
"connection log %s (type=%s) should have a session", cl.ID, cl.Type)
|
||||
require.Equal(t, existingSessionID, cl.SessionID.UUID,
|
||||
"connection log %s should reuse the existing session, not create a new one", cl.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: connections with different IPs but same hostname get grouped
|
||||
// into one session.
|
||||
func TestCloseConnectionLogsAndCreateSessions_GroupsByHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
o := dbgen.Organization(t, db, database.Organization{})
|
||||
tpl := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: o.ID,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: u.ID,
|
||||
OrganizationID: o.ID,
|
||||
AutomaticUpdates: database.AutomaticUpdatesNever,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
|
||||
now := dbtime.Now()
|
||||
hostname := sql.NullString{String: "my-laptop", Valid: true}
|
||||
|
||||
// Create 3 SSH connections with different IPs but same hostname,
|
||||
// overlapping in time.
|
||||
for i := 0; i < 3; i++ {
|
||||
ip := pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(10, 0, 0, byte(i+1)),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
}
|
||||
_, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(time.Duration(-30+i*5) * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ClientHostname: hostname,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
closedAt := now
|
||||
_, err := db.CloseConnectionLogsAndCreateSessions(ctx, database.CloseConnectionLogsAndCreateSessionsParams{
|
||||
ClosedAt: sql.NullTime{Time: closedAt, Valid: true},
|
||||
Reason: sql.NullString{String: "workspace stopped", Valid: true},
|
||||
WorkspaceID: ws.ID,
|
||||
Types: []database.ConnectionType{
|
||||
database.ConnectionTypeSsh,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{
|
||||
WorkspaceID: ws.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 3)
|
||||
|
||||
// All 3 connections should have the same session_id.
|
||||
var sessionID uuid.UUID
|
||||
for i, row := range rows {
|
||||
cl := row.ConnectionLog
|
||||
require.True(t, cl.SessionID.Valid,
|
||||
"connection %d should have a session", i)
|
||||
if i == 0 {
|
||||
sessionID = cl.SessionID.UUID
|
||||
} else {
|
||||
require.Equal(t, sessionID, cl.SessionID.UUID,
|
||||
"all connections with same hostname should share one session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test: a long-running system connection gets attached to the first
|
||||
// overlapping primary session, not the second.
|
||||
func TestCloseConnectionLogsAndCreateSessions_SystemAttachesToFirstSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
o := dbgen.Organization(t, db, database.Organization{})
|
||||
tpl := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: o.ID,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: u.ID,
|
||||
OrganizationID: o.ID,
|
||||
AutomaticUpdates: database.AutomaticUpdatesNever,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
|
||||
ip := pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(10, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
}
|
||||
now := dbtime.Now()
|
||||
|
||||
// System connection spanning the full workspace lifetime.
|
||||
sysLog, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-3 * time.Hour),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSystem,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// SSH session 1: -3h to -2h.
|
||||
ssh1ConnID := uuid.New()
|
||||
_, err = db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-3 * time.Hour),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: ssh1ConnID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ssh1Disc, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-2 * time.Hour),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: ssh1ConnID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusDisconnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_ = ssh1Disc
|
||||
|
||||
// SSH session 2: -30min to now (>30min gap from session 1).
|
||||
_, err = db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-30 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
closedAt := now
|
||||
_, err = db.CloseConnectionLogsAndCreateSessions(ctx, database.CloseConnectionLogsAndCreateSessionsParams{
|
||||
ClosedAt: sql.NullTime{Time: closedAt, Valid: true},
|
||||
Reason: sql.NullString{String: "workspace stopped", Valid: true},
|
||||
WorkspaceID: ws.ID,
|
||||
Types: []database.ConnectionType{
|
||||
database.ConnectionTypeSsh,
|
||||
database.ConnectionTypeSystem,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{
|
||||
WorkspaceID: ws.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find the system connection and its assigned session.
|
||||
var sysSessionID uuid.UUID
|
||||
// Collect all session IDs from SSH connections to verify 2
|
||||
// distinct sessions were created.
|
||||
sshSessionIDs := make(map[uuid.UUID]bool)
|
||||
for _, row := range rows {
|
||||
cl := row.ConnectionLog
|
||||
if cl.ID == sysLog.ID {
|
||||
require.True(t, cl.SessionID.Valid,
|
||||
"system connection should have a session")
|
||||
sysSessionID = cl.SessionID.UUID
|
||||
}
|
||||
if cl.Type == database.ConnectionTypeSsh && cl.SessionID.Valid {
|
||||
sshSessionIDs[cl.SessionID.UUID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Two distinct SSH sessions should exist (>30min gap).
|
||||
require.Len(t, sshSessionIDs, 2, "should have 2 distinct SSH sessions")
|
||||
|
||||
// System connection should be attached to the first (earliest)
|
||||
// session.
|
||||
require.True(t, sshSessionIDs[sysSessionID],
|
||||
"system connection should be attached to one of the SSH sessions")
|
||||
}
|
||||
|
||||
// Test: an orphaned system connection (no overlapping primary sessions)
|
||||
// with an IP gets its own session.
|
||||
func TestCloseConnectionLogsAndCreateSessions_OrphanSystemGetsOwnSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
o := dbgen.Organization(t, db, database.Organization{})
|
||||
tpl := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: o.ID,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: u.ID,
|
||||
OrganizationID: o.ID,
|
||||
AutomaticUpdates: database.AutomaticUpdatesNever,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
|
||||
ip := pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(10, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
}
|
||||
now := dbtime.Now()
|
||||
|
||||
// System connection with an IP but no overlapping primary
|
||||
// connections.
|
||||
_, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-10 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSystem,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
closedAt := now
|
||||
_, err = db.CloseConnectionLogsAndCreateSessions(ctx, database.CloseConnectionLogsAndCreateSessionsParams{
|
||||
ClosedAt: sql.NullTime{Time: closedAt, Valid: true},
|
||||
Reason: sql.NullString{String: "workspace stopped", Valid: true},
|
||||
WorkspaceID: ws.ID,
|
||||
Types: []database.ConnectionType{
|
||||
database.ConnectionTypeSystem,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{
|
||||
WorkspaceID: ws.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
require.True(t, rows[0].ConnectionLog.SessionID.Valid,
|
||||
"orphaned system connection with IP should get its own session")
|
||||
}
|
||||
|
||||
// Test: a system connection with NULL IP and no overlapping primary
|
||||
// sessions gets no session (can't create a useful session without IP).
|
||||
func TestCloseConnectionLogsAndCreateSessions_SystemNoIPNoSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
o := dbgen.Organization(t, db, database.Organization{})
|
||||
tpl := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: o.ID,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: u.ID,
|
||||
OrganizationID: o.ID,
|
||||
AutomaticUpdates: database.AutomaticUpdatesNever,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// System connection with NULL IP and no overlapping primary.
|
||||
_, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-10 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSystem,
|
||||
Ip: pqtype.Inet{Valid: false},
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
closedAt := now
|
||||
_, err = db.CloseConnectionLogsAndCreateSessions(ctx, database.CloseConnectionLogsAndCreateSessionsParams{
|
||||
ClosedAt: sql.NullTime{Time: closedAt, Valid: true},
|
||||
Reason: sql.NullString{String: "workspace stopped", Valid: true},
|
||||
WorkspaceID: ws.ID,
|
||||
Types: []database.ConnectionType{
|
||||
database.ConnectionTypeSystem,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{
|
||||
WorkspaceID: ws.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
require.True(t, rows[0].ConnectionLog.DisconnectTime.Valid,
|
||||
"system connection should be closed")
|
||||
require.False(t, rows[0].ConnectionLog.SessionID.Valid,
|
||||
"NULL-IP system connection with no primary overlap should not get a session")
|
||||
}
|
||||
|
||||
// Test: connections from the same hostname with a >30-minute gap
|
||||
// create separate sessions.
|
||||
func TestCloseConnectionLogsAndCreateSessions_SeparateSessionsForLargeGap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u := dbgen.User(t, db, database.User{})
|
||||
o := dbgen.Organization(t, db, database.Organization{})
|
||||
tpl := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: o.ID,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: u.ID,
|
||||
OrganizationID: o.ID,
|
||||
AutomaticUpdates: database.AutomaticUpdatesNever,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
|
||||
ip := pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(10, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
}
|
||||
now := dbtime.Now()
|
||||
|
||||
// SSH connection 1: -3h to -2h.
|
||||
conn1ID := uuid.New()
|
||||
_, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-3 * time.Hour),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: conn1ID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-2 * time.Hour),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: conn1ID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusDisconnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// SSH connection 2: -30min to now (>30min gap from connection 1).
|
||||
_, err = db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: now.Add(-30 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
closedAt := now
|
||||
_, err = db.CloseConnectionLogsAndCreateSessions(ctx, database.CloseConnectionLogsAndCreateSessionsParams{
|
||||
ClosedAt: sql.NullTime{Time: closedAt, Valid: true},
|
||||
Reason: sql.NullString{String: "workspace stopped", Valid: true},
|
||||
WorkspaceID: ws.ID,
|
||||
Types: []database.ConnectionType{
|
||||
database.ConnectionTypeSsh,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{
|
||||
WorkspaceID: ws.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sessionIDs := make(map[uuid.UUID]bool)
|
||||
for _, row := range rows {
|
||||
cl := row.ConnectionLog
|
||||
if cl.SessionID.Valid {
|
||||
sessionIDs[cl.SessionID.UUID] = true
|
||||
}
|
||||
}
|
||||
require.Len(t, sessionIDs, 2,
|
||||
"connections with >30min gap should create 2 separate sessions")
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
)
|
||||
|
||||
func TestGetOngoingAgentConnectionsLast24h(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
org := dbfake.Organization(t, db).Do()
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
tpl := dbgen.Template(t, db, database.Template{OrganizationID: org.Org.ID, CreatedBy: user.ID})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OrganizationID: org.Org.ID,
|
||||
OwnerID: user.ID,
|
||||
TemplateID: tpl.ID,
|
||||
Name: "ws",
|
||||
})
|
||||
|
||||
now := dbtime.Now()
|
||||
since := now.Add(-24 * time.Hour)
|
||||
|
||||
const (
|
||||
agent1 = "agent1"
|
||||
agent2 = "agent2"
|
||||
)
|
||||
|
||||
// Insert a disconnected log that should be excluded.
|
||||
disconnectedConnID := uuid.New()
|
||||
disconnected := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-30 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: agent1,
|
||||
Type: database.ConnectionTypeSsh,
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
ConnectionID: uuid.NullUUID{UUID: disconnectedConnID, Valid: true},
|
||||
})
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-20 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
AgentName: disconnected.AgentName,
|
||||
ConnectionStatus: database.ConnectionStatusDisconnected,
|
||||
ConnectionID: disconnected.ConnectionID,
|
||||
DisconnectReason: sql.NullString{String: "closed", Valid: true},
|
||||
})
|
||||
|
||||
// Insert an old log that should be excluded by the 24h window.
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-25 * time.Hour),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: agent1,
|
||||
Type: database.ConnectionTypeSsh,
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
})
|
||||
|
||||
// Insert a web log that should be excluded by the types filter.
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-10 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: agent1,
|
||||
Type: database.ConnectionTypeWorkspaceApp,
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
})
|
||||
|
||||
// Insert 55 active logs for agent1 (should be capped to 50).
|
||||
for i := 0; i < 55; i++ {
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-time.Duration(i) * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: agent1,
|
||||
Type: database.ConnectionTypeVscode,
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
// Insert one active log for agent2.
|
||||
agent2Log := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-5 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: agent2,
|
||||
Type: database.ConnectionTypeJetbrains,
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
})
|
||||
|
||||
logs, err := db.GetOngoingAgentConnectionsLast24h(ctx, database.GetOngoingAgentConnectionsLast24hParams{
|
||||
WorkspaceIds: []uuid.UUID{ws.ID},
|
||||
AgentNames: []string{agent1, agent2},
|
||||
Types: []database.ConnectionType{database.ConnectionTypeSsh, database.ConnectionTypeVscode, database.ConnectionTypeJetbrains, database.ConnectionTypeReconnectingPty},
|
||||
Since: since,
|
||||
PerAgentLimit: 50,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
byAgent := map[string][]database.GetOngoingAgentConnectionsLast24hRow{}
|
||||
for _, l := range logs {
|
||||
byAgent[l.AgentName] = append(byAgent[l.AgentName], l)
|
||||
}
|
||||
|
||||
// Agent1 should be capped at 50 and contain only active logs within the window.
|
||||
require.Len(t, byAgent[agent1], 50)
|
||||
for i, l := range byAgent[agent1] {
|
||||
require.False(t, l.DisconnectTime.Valid, "expected log to be ongoing")
|
||||
require.True(t, l.ConnectTime.After(since) || l.ConnectTime.Equal(since), "expected log to be within window")
|
||||
if i > 0 {
|
||||
require.True(t, byAgent[agent1][i-1].ConnectTime.After(l.ConnectTime) || byAgent[agent1][i-1].ConnectTime.Equal(l.ConnectTime), "expected logs to be ordered by connect_time desc")
|
||||
}
|
||||
}
|
||||
|
||||
// Agent2 should include its single active log.
|
||||
require.Equal(t, []uuid.UUID{agent2Log.ID}, []uuid.UUID{byAgent[agent2][0].ID})
|
||||
}
|
||||
|
||||
func TestGetOngoingAgentConnectionsLast24h_PortForwarding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
org := dbfake.Organization(t, db).Do()
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
tpl := dbgen.Template(t, db, database.Template{OrganizationID: org.Org.ID, CreatedBy: user.ID})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OrganizationID: org.Org.ID,
|
||||
OwnerID: user.ID,
|
||||
TemplateID: tpl.ID,
|
||||
Name: "ws-pf",
|
||||
})
|
||||
|
||||
now := dbtime.Now()
|
||||
since := now.Add(-24 * time.Hour)
|
||||
|
||||
const agentName = "agent-pf"
|
||||
|
||||
// Agent-reported: NULL user_agent, included unconditionally.
|
||||
agentReported := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-10 * time.Minute),
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: agentName,
|
||||
Type: database.ConnectionTypePortForwarding,
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
SlugOrPort: sql.NullString{String: "8080", Valid: true},
|
||||
Ip: database.ParseIP("fd7a:115c:a1e0:4353:89d9:4ca8:9c42:8d2d"),
|
||||
})
|
||||
|
||||
// Stale proxy-reported: non-NULL user_agent, bumped but older than AppActiveSince.
|
||||
// Use a non-localhost IP to verify the fix works even behind a reverse proxy.
|
||||
staleConnID := uuid.New()
|
||||
staleConnectTime := now.Add(-15 * time.Minute)
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: staleConnectTime,
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: agentName,
|
||||
Type: database.ConnectionTypePortForwarding,
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
ConnectionID: uuid.NullUUID{UUID: staleConnID, Valid: true},
|
||||
SlugOrPort: sql.NullString{String: "3000", Valid: true},
|
||||
Ip: database.ParseIP("203.0.113.45"),
|
||||
UserAgent: sql.NullString{String: "Mozilla/5.0", Valid: true},
|
||||
})
|
||||
|
||||
// Bump updated_at to simulate a proxy refresh.
|
||||
staleBumpTime := now.Add(-8 * time.Minute)
|
||||
_, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: staleBumpTime,
|
||||
OrganizationID: ws.OrganizationID,
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceID: ws.ID,
|
||||
WorkspaceName: ws.Name,
|
||||
AgentName: agentName,
|
||||
Type: database.ConnectionTypePortForwarding,
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
ConnectionID: uuid.NullUUID{UUID: staleConnID, Valid: true},
|
||||
SlugOrPort: sql.NullString{String: "3000", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
appActiveSince := now.Add(-5 * time.Minute)
|
||||
|
||||
logs, err := db.GetOngoingAgentConnectionsLast24h(ctx, database.GetOngoingAgentConnectionsLast24hParams{
|
||||
WorkspaceIds: []uuid.UUID{ws.ID},
|
||||
AgentNames: []string{agentName},
|
||||
Types: []database.ConnectionType{database.ConnectionTypePortForwarding},
|
||||
Since: since,
|
||||
PerAgentLimit: 50,
|
||||
AppActiveSince: appActiveSince,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Only the agent-reported connection should appear.
|
||||
require.Len(t, logs, 1)
|
||||
require.Equal(t, agentReported.ID, logs[0].ID)
|
||||
require.Equal(t, database.ConnectionTypePortForwarding, logs[0].Type)
|
||||
require.True(t, logs[0].SlugOrPort.Valid)
|
||||
require.Equal(t, "8080", logs[0].SlugOrPort.String)
|
||||
}
|
||||
@@ -3,3 +3,12 @@ package database
|
||||
import "github.com/google/uuid"
|
||||
|
||||
var PrebuildsSystemUserID = uuid.MustParse("c42fdf75-3097-471c-8c33-fb52454d81c0")
|
||||
|
||||
const (
|
||||
TailnetPeeringEventTypeAddedTunnel = "added_tunnel"
|
||||
TailnetPeeringEventTypeRemovedTunnel = "removed_tunnel"
|
||||
TailnetPeeringEventTypePeerUpdateNode = "peer_update_node"
|
||||
TailnetPeeringEventTypePeerUpdateDisconnected = "peer_update_disconnected"
|
||||
TailnetPeeringEventTypePeerUpdateLost = "peer_update_lost"
|
||||
TailnetPeeringEventTypePeerUpdateReadyForHandshake = "peer_update_ready_for_handshake"
|
||||
)
|
||||
|
||||
@@ -849,6 +849,10 @@ func ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ agentproto.Conn
|
||||
return database.ConnectionTypeVscode, nil
|
||||
case agentproto.Connection_RECONNECTING_PTY:
|
||||
return database.ConnectionTypeReconnectingPty, nil
|
||||
case agentproto.Connection_WORKSPACE_APP:
|
||||
return database.ConnectionTypeWorkspaceApp, nil
|
||||
case agentproto.Connection_PORT_FORWARDING:
|
||||
return database.ConnectionTypePortForwarding, nil
|
||||
default:
|
||||
// Also Connection_TYPE_UNSPECIFIED, no mapping.
|
||||
return "", xerrors.Errorf("unknown agent connection type %q", typ)
|
||||
|
||||
@@ -461,6 +461,24 @@ var (
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
subjectTailnetCoordinator = rbac.Subject{
|
||||
Type: rbac.SubjectTypeTailnetCoordinator,
|
||||
FriendlyName: "Tailnet Coordinator",
|
||||
ID: uuid.Nil.String(),
|
||||
Roles: rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleIdentifier{Name: "tailnetcoordinator"},
|
||||
DisplayName: "Tailnet Coordinator",
|
||||
Site: rbac.Permissions(map[string][]policy.Action{
|
||||
rbac.ResourceTailnetCoordinator.Type: {policy.WildcardSymbol},
|
||||
}),
|
||||
User: []rbac.Permission{},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{},
|
||||
},
|
||||
}),
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
subjectSystemOAuth2 = rbac.Subject{
|
||||
Type: rbac.SubjectTypeSystemOAuth,
|
||||
FriendlyName: "System OAuth2",
|
||||
@@ -726,6 +744,12 @@ func AsSystemRestricted(ctx context.Context) context.Context {
|
||||
return As(ctx, subjectSystemRestricted)
|
||||
}
|
||||
|
||||
// AsTailnetCoordinator returns a context with an actor that has permissions
|
||||
// required for tailnet coordinator operations.
|
||||
func AsTailnetCoordinator(ctx context.Context) context.Context {
|
||||
return As(ctx, subjectTailnetCoordinator)
|
||||
}
|
||||
|
||||
// AsSystemOAuth2 returns a context with an actor that has permissions
|
||||
// required for OAuth2 provider operations (token revocation, device codes, registration).
|
||||
func AsSystemOAuth2(ctx context.Context) context.Context {
|
||||
@@ -1588,6 +1612,20 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error {
|
||||
return q.db.CleanTailnetTunnels(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) CloseConnectionLogsAndCreateSessions(ctx context.Context, arg database.CloseConnectionLogsAndCreateSessionsParams) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.db.CloseConnectionLogsAndCreateSessions(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) CloseOpenAgentConnectionLogsForWorkspace(ctx context.Context, arg database.CloseOpenAgentConnectionLogsForWorkspaceParams) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.db.CloseOpenAgentConnectionLogsForWorkspace(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) CountAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams) (int64, error) {
|
||||
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
|
||||
if err != nil {
|
||||
@@ -1623,6 +1661,13 @@ func (q *querier) CountConnectionLogs(ctx context.Context, arg database.CountCon
|
||||
return q.db.CountAuthorizedConnectionLogs(ctx, arg, prep)
|
||||
}
|
||||
|
||||
func (q *querier) CountGlobalWorkspaceSessions(ctx context.Context, arg database.CountGlobalWorkspaceSessionsParams) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.db.CountGlobalWorkspaceSessions(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) CountInProgressPrebuilds(ctx context.Context) ([]database.CountInProgressPrebuildsRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.All()); err != nil {
|
||||
return nil, err
|
||||
@@ -1644,6 +1689,13 @@ func (q *querier) CountUnreadInboxNotificationsByUserID(ctx context.Context, use
|
||||
return q.db.CountUnreadInboxNotificationsByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) CountWorkspaceSessions(ctx context.Context, arg database.CountWorkspaceSessionsParams) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.db.CountWorkspaceSessions(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) {
|
||||
obj := rbac.ResourceUserSecret.WithOwner(arg.UserID.String())
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, obj); err != nil {
|
||||
@@ -2118,6 +2170,13 @@ func (q *querier) FindMatchingPresetID(ctx context.Context, arg database.FindMat
|
||||
return q.db.FindMatchingPresetID(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) FindOrCreateSessionForDisconnect(ctx context.Context, arg database.FindOrCreateSessionForDisconnectParams) (interface{}, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.FindOrCreateSessionForDisconnect(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (database.AIBridgeInterception, error) {
|
||||
return fetch(q.log, q.auth, q.db.GetAIBridgeInterceptionByID)(ctx, id)
|
||||
}
|
||||
@@ -2202,6 +2261,13 @@ func (q *querier) GetAllTailnetCoordinators(ctx context.Context) ([]database.Tai
|
||||
return q.db.GetAllTailnetCoordinators(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetAllTailnetPeeringEventsByPeerID(ctx context.Context, srcPeerID uuid.NullUUID) ([]database.TailnetPeeringEvent, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetAllTailnetPeeringEventsByPeerID(ctx, srcPeerID)
|
||||
}
|
||||
|
||||
func (q *querier) GetAllTailnetPeers(ctx context.Context) ([]database.TailnetPeer, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
|
||||
return nil, err
|
||||
@@ -2271,6 +2337,20 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI
|
||||
return q.db.GetAuthorizationUserRoles(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetConnectionLogByConnectionID(ctx context.Context, arg database.GetConnectionLogByConnectionIDParams) (database.ConnectionLog, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog); err != nil {
|
||||
return database.ConnectionLog{}, err
|
||||
}
|
||||
return q.db.GetConnectionLogByConnectionID(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetConnectionLogsBySessionIDs(ctx context.Context, sessionIDs []uuid.UUID) ([]database.ConnectionLog, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetConnectionLogsBySessionIDs(ctx, sessionIDs)
|
||||
}
|
||||
|
||||
func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
|
||||
// Just like with the audit logs query, shortcut if the user is an owner.
|
||||
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog)
|
||||
@@ -2446,6 +2526,13 @@ func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.
|
||||
return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetGitSSHKey)(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetGlobalWorkspaceSessionsOffset(ctx context.Context, arg database.GetGlobalWorkspaceSessionsOffsetParams) ([]database.GetGlobalWorkspaceSessionsOffsetRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetGlobalWorkspaceSessionsOffset(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetGroupByID(ctx context.Context, id uuid.UUID) (database.Group, error) {
|
||||
return fetch(q.log, q.auth, q.db.GetGroupByID)(ctx, id)
|
||||
}
|
||||
@@ -2712,6 +2799,15 @@ func (q *querier) GetOAuthSigningKey(ctx context.Context) (string, error) {
|
||||
return q.db.GetOAuthSigningKey(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetOngoingAgentConnectionsLast24h(ctx context.Context, arg database.GetOngoingAgentConnectionsLast24hParams) ([]database.GetOngoingAgentConnectionsLast24hRow, error) {
|
||||
// This is a system-level read; authorization comes from the
|
||||
// caller using dbauthz.AsSystemRestricted(ctx).
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetOngoingAgentConnectionsLast24h(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (database.Organization, error) {
|
||||
return fetch(q.log, q.auth, q.db.GetOrganizationByID)(ctx, id)
|
||||
}
|
||||
@@ -3081,6 +3177,13 @@ func (q *querier) GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.U
|
||||
return q.db.GetTailnetTunnelPeerBindings(ctx, srcID)
|
||||
}
|
||||
|
||||
func (q *querier) GetTailnetTunnelPeerBindingsByDstID(ctx context.Context, dstID uuid.UUID) ([]database.GetTailnetTunnelPeerBindingsByDstIDRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetTailnetTunnelPeerBindingsByDstID(ctx, dstID)
|
||||
}
|
||||
|
||||
func (q *querier) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]database.GetTailnetTunnelPeerIDsRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
|
||||
return nil, err
|
||||
@@ -4086,6 +4189,13 @@ func (q *querier) GetWorkspaceResourcesCreatedAfter(ctx context.Context, created
|
||||
return q.db.GetWorkspaceResourcesCreatedAfter(ctx, createdAt)
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceSessionsOffset(ctx context.Context, arg database.GetWorkspaceSessionsOffsetParams) ([]database.GetWorkspaceSessionsOffsetRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetWorkspaceSessionsOffset(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIDs []uuid.UUID) ([]database.GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
@@ -4400,6 +4510,13 @@ func (q *querier) InsertReplica(ctx context.Context, arg database.InsertReplicaP
|
||||
return q.db.InsertReplica(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertTailnetPeeringEvent(ctx context.Context, arg database.InsertTailnetPeeringEventParams) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceTailnetCoordinator); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.InsertTailnetPeeringEvent(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertTask(ctx context.Context, arg database.InsertTaskParams) (database.TaskTable, error) {
|
||||
// Ensure the actor can access the specified template version (and thus its template).
|
||||
if _, err := q.GetTemplateVersionByID(ctx, arg.TemplateVersionID); err != nil {
|
||||
@@ -4948,6 +5065,13 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe
|
||||
return update(q.log, q.auth, fetch, q.db.UpdateAPIKeyByID)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateConnectionLogSessionID(ctx context.Context, arg database.UpdateConnectionLogSessionIDParams) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.UpdateConnectionLogSessionID(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceCryptoKey); err != nil {
|
||||
return database.CryptoKey{}, err
|
||||
@@ -6202,9 +6326,9 @@ func (q *querier) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWor
|
||||
return q.db.UpsertWorkspaceApp(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) {
|
||||
func (q *querier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (database.UpsertWorkspaceAppAuditSessionRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
|
||||
return false, err
|
||||
return database.UpsertWorkspaceAppAuditSessionRow{}, err
|
||||
}
|
||||
return q.db.UpsertWorkspaceAppAuditSession(ctx, arg)
|
||||
}
|
||||
|
||||
@@ -362,6 +362,11 @@ func (s *MethodTestSuite) TestConnectionLogs() {
|
||||
dbm.EXPECT().DeleteOldConnectionLogs(gomock.Any(), database.DeleteOldConnectionLogsParams{}).Return(int64(0), nil).AnyTimes()
|
||||
check.Args(database.DeleteOldConnectionLogsParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete)
|
||||
}))
|
||||
s.Run("CloseOpenAgentConnectionLogsForWorkspace", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.CloseOpenAgentConnectionLogsForWorkspaceParams{}
|
||||
dbm.EXPECT().CloseOpenAgentConnectionLogsForWorkspace(gomock.Any(), arg).Return(int64(0), nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceConnectionLog, policy.ActionUpdate)
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestFile() {
|
||||
@@ -2841,6 +2846,10 @@ func (s *MethodTestSuite) TestTailnetFunctions() {
|
||||
check.Args(uuid.New()).
|
||||
Asserts(rbac.ResourceTailnetCoordinator, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetTailnetTunnelPeerBindingsByDstID", s.Subtest(func(_ database.Store, check *expects) {
|
||||
check.Args(uuid.New()).
|
||||
Asserts(rbac.ResourceTailnetCoordinator, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetTailnetTunnelPeerIDs", s.Subtest(func(_ database.Store, check *expects) {
|
||||
check.Args(uuid.New()).
|
||||
Asserts(rbac.ResourceTailnetCoordinator, policy.ActionRead)
|
||||
@@ -3309,7 +3318,7 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
agent := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
|
||||
app := testutil.Fake(s.T(), faker, database.WorkspaceApp{})
|
||||
arg := database.UpsertWorkspaceAppAuditSessionParams{AgentID: agent.ID, AppID: app.ID, UserID: u.ID, Ip: "127.0.0.1"}
|
||||
dbm.EXPECT().UpsertWorkspaceAppAuditSession(gomock.Any(), arg).Return(true, nil).AnyTimes()
|
||||
dbm.EXPECT().UpsertWorkspaceAppAuditSession(gomock.Any(), arg).Return(database.UpsertWorkspaceAppAuditSessionRow{NewOrStale: true}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("InsertWorkspaceAgentScriptTimings", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
|
||||
@@ -35,12 +35,25 @@ import (
|
||||
var errMatchAny = xerrors.New("match any error")
|
||||
|
||||
var skipMethods = map[string]string{
|
||||
"InTx": "Not relevant",
|
||||
"Ping": "Not relevant",
|
||||
"PGLocks": "Not relevant",
|
||||
"Wrappers": "Not relevant",
|
||||
"AcquireLock": "Not relevant",
|
||||
"TryAcquireLock": "Not relevant",
|
||||
|
||||
"InTx": "Not relevant",
|
||||
"Ping": "Not relevant",
|
||||
"PGLocks": "Not relevant",
|
||||
"Wrappers": "Not relevant",
|
||||
"AcquireLock": "Not relevant",
|
||||
"TryAcquireLock": "Not relevant",
|
||||
"GetOngoingAgentConnectionsLast24h": "Hackathon",
|
||||
"InsertTailnetPeeringEvent": "Hackathon",
|
||||
"CloseConnectionLogsAndCreateSessions": "Hackathon",
|
||||
"CountGlobalWorkspaceSessions": "Hackathon",
|
||||
"CountWorkspaceSessions": "Hackathon",
|
||||
"FindOrCreateSessionForDisconnect": "Hackathon",
|
||||
"GetConnectionLogByConnectionID": "Hackathon",
|
||||
"GetConnectionLogsBySessionIDs": "Hackathon",
|
||||
"GetGlobalWorkspaceSessionsOffset": "Hackathon",
|
||||
"GetWorkspaceSessionsOffset": "Hackathon",
|
||||
"UpdateConnectionLogSessionID": "Hackathon",
|
||||
"GetAllTailnetPeeringEventsByPeerID": "Hackathon",
|
||||
}
|
||||
|
||||
// TestMethodTestSuite runs MethodTestSuite.
|
||||
|
||||
@@ -86,18 +86,27 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.UpsertConnecti
|
||||
WorkspaceID: takeFirst(seed.WorkspaceID, uuid.New()),
|
||||
WorkspaceName: takeFirst(seed.WorkspaceName, testutil.GetRandomName(t)),
|
||||
AgentName: takeFirst(seed.AgentName, testutil.GetRandomName(t)),
|
||||
Type: takeFirst(seed.Type, database.ConnectionTypeSsh),
|
||||
AgentID: uuid.NullUUID{
|
||||
UUID: takeFirst(seed.AgentID.UUID, uuid.Nil),
|
||||
Valid: takeFirst(seed.AgentID.Valid, false),
|
||||
},
|
||||
Type: takeFirst(seed.Type, database.ConnectionTypeSsh),
|
||||
Code: sql.NullInt32{
|
||||
Int32: takeFirst(seed.Code.Int32, 0),
|
||||
Valid: takeFirst(seed.Code.Valid, false),
|
||||
},
|
||||
Ip: pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(127, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
},
|
||||
Ip: func() pqtype.Inet {
|
||||
if seed.Ip.Valid {
|
||||
return seed.Ip
|
||||
}
|
||||
return pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(127, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
}
|
||||
}(),
|
||||
UserAgent: sql.NullString{
|
||||
String: takeFirst(seed.UserAgent.String, ""),
|
||||
Valid: takeFirst(seed.UserAgent.Valid, false),
|
||||
@@ -118,6 +127,18 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.UpsertConnecti
|
||||
String: takeFirst(seed.DisconnectReason.String, ""),
|
||||
Valid: takeFirst(seed.DisconnectReason.Valid, false),
|
||||
},
|
||||
SessionID: uuid.NullUUID{
|
||||
UUID: takeFirst(seed.SessionID.UUID, uuid.Nil),
|
||||
Valid: takeFirst(seed.SessionID.Valid, false),
|
||||
},
|
||||
ClientHostname: sql.NullString{
|
||||
String: takeFirst(seed.ClientHostname.String, ""),
|
||||
Valid: takeFirst(seed.ClientHostname.Valid, false),
|
||||
},
|
||||
ShortDescription: sql.NullString{
|
||||
String: takeFirst(seed.ShortDescription.String, ""),
|
||||
Valid: takeFirst(seed.ShortDescription.Valid, false),
|
||||
},
|
||||
ConnectionStatus: takeFirst(seed.ConnectionStatus, database.ConnectionStatusConnected),
|
||||
})
|
||||
require.NoError(t, err, "insert connection log")
|
||||
|
||||
@@ -231,6 +231,22 @@ func (m queryMetricsStore) CleanTailnetTunnels(ctx context.Context) error {
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CloseConnectionLogsAndCreateSessions(ctx context.Context, arg database.CloseConnectionLogsAndCreateSessionsParams) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CloseConnectionLogsAndCreateSessions(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("CloseConnectionLogsAndCreateSessions").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CloseConnectionLogsAndCreateSessions").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CloseOpenAgentConnectionLogsForWorkspace(ctx context.Context, arg database.CloseOpenAgentConnectionLogsForWorkspaceParams) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CloseOpenAgentConnectionLogsForWorkspace(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("CloseOpenAgentConnectionLogsForWorkspace").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CloseOpenAgentConnectionLogsForWorkspace").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CountAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CountAIBridgeInterceptions(ctx, arg)
|
||||
@@ -255,6 +271,14 @@ func (m queryMetricsStore) CountConnectionLogs(ctx context.Context, arg database
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CountGlobalWorkspaceSessions(ctx context.Context, arg database.CountGlobalWorkspaceSessionsParams) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CountGlobalWorkspaceSessions(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("CountGlobalWorkspaceSessions").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountGlobalWorkspaceSessions").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CountInProgressPrebuilds(ctx context.Context) ([]database.CountInProgressPrebuildsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CountInProgressPrebuilds(ctx)
|
||||
@@ -279,6 +303,14 @@ func (m queryMetricsStore) CountUnreadInboxNotificationsByUserID(ctx context.Con
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CountWorkspaceSessions(ctx context.Context, arg database.CountWorkspaceSessionsParams) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CountWorkspaceSessions(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("CountWorkspaceSessions").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountWorkspaceSessions").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CreateUserSecret(ctx, arg)
|
||||
@@ -718,6 +750,14 @@ func (m queryMetricsStore) FindMatchingPresetID(ctx context.Context, arg databas
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) FindOrCreateSessionForDisconnect(ctx context.Context, arg database.FindOrCreateSessionForDisconnectParams) (interface{}, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.FindOrCreateSessionForDisconnect(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("FindOrCreateSessionForDisconnect").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "FindOrCreateSessionForDisconnect").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (database.AIBridgeInterception, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetAIBridgeInterceptionByID(ctx, id)
|
||||
@@ -830,6 +870,14 @@ func (m queryMetricsStore) GetAllTailnetCoordinators(ctx context.Context) ([]dat
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetAllTailnetPeeringEventsByPeerID(ctx context.Context, srcPeerID uuid.NullUUID) ([]database.TailnetPeeringEvent, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetAllTailnetPeeringEventsByPeerID(ctx, srcPeerID)
|
||||
m.queryLatencies.WithLabelValues("GetAllTailnetPeeringEventsByPeerID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAllTailnetPeeringEventsByPeerID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetAllTailnetPeers(ctx context.Context) ([]database.TailnetPeer, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetAllTailnetPeers(ctx)
|
||||
@@ -902,6 +950,22 @@ func (m queryMetricsStore) GetAuthorizationUserRoles(ctx context.Context, userID
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetConnectionLogByConnectionID(ctx context.Context, arg database.GetConnectionLogByConnectionIDParams) (database.ConnectionLog, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetConnectionLogByConnectionID(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetConnectionLogByConnectionID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetConnectionLogByConnectionID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetConnectionLogsBySessionIDs(ctx context.Context, sessionIds []uuid.UUID) ([]database.ConnectionLog, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetConnectionLogsBySessionIDs(ctx, sessionIds)
|
||||
m.queryLatencies.WithLabelValues("GetConnectionLogsBySessionIDs").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetConnectionLogsBySessionIDs").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetConnectionLogsOffset(ctx, arg)
|
||||
@@ -1094,6 +1158,14 @@ func (m queryMetricsStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetGlobalWorkspaceSessionsOffset(ctx context.Context, arg database.GetGlobalWorkspaceSessionsOffsetParams) ([]database.GetGlobalWorkspaceSessionsOffsetRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetGlobalWorkspaceSessionsOffset(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetGlobalWorkspaceSessionsOffset").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetGlobalWorkspaceSessionsOffset").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetGroupByID(ctx context.Context, id uuid.UUID) (database.Group, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetGroupByID(ctx, id)
|
||||
@@ -1390,6 +1462,14 @@ func (m queryMetricsStore) GetOAuthSigningKey(ctx context.Context) (string, erro
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetOngoingAgentConnectionsLast24h(ctx context.Context, arg database.GetOngoingAgentConnectionsLast24hParams) ([]database.GetOngoingAgentConnectionsLast24hRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetOngoingAgentConnectionsLast24h(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetOngoingAgentConnectionsLast24h").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetOngoingAgentConnectionsLast24h").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetOrganizationByID(ctx context.Context, id uuid.UUID) (database.Organization, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetOrganizationByID(ctx, id)
|
||||
@@ -1734,6 +1814,14 @@ func (m queryMetricsStore) GetTailnetTunnelPeerBindings(ctx context.Context, src
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetTailnetTunnelPeerBindingsByDstID(ctx context.Context, dstID uuid.UUID) ([]database.GetTailnetTunnelPeerBindingsByDstIDRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetTailnetTunnelPeerBindingsByDstID(ctx, dstID)
|
||||
m.queryLatencies.WithLabelValues("GetTailnetTunnelPeerBindingsByDstID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetTailnetTunnelPeerBindingsByDstID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]database.GetTailnetTunnelPeerIDsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetTailnetTunnelPeerIDs(ctx, srcID)
|
||||
@@ -2590,6 +2678,14 @@ func (m queryMetricsStore) GetWorkspaceResourcesCreatedAfter(ctx context.Context
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetWorkspaceSessionsOffset(ctx context.Context, arg database.GetWorkspaceSessionsOffsetParams) ([]database.GetWorkspaceSessionsOffsetRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetWorkspaceSessionsOffset(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetWorkspaceSessionsOffset").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspaceSessionsOffset").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]database.GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx, templateIds)
|
||||
@@ -2918,6 +3014,14 @@ func (m queryMetricsStore) InsertReplica(ctx context.Context, arg database.Inser
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertTailnetPeeringEvent(ctx context.Context, arg database.InsertTailnetPeeringEventParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.InsertTailnetPeeringEvent(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertTailnetPeeringEvent").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertTailnetPeeringEvent").Inc()
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertTask(ctx context.Context, arg database.InsertTaskParams) (database.TaskTable, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertTask(ctx, arg)
|
||||
@@ -3390,6 +3494,14 @@ func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.Up
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateConnectionLogSessionID(ctx context.Context, arg database.UpdateConnectionLogSessionIDParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpdateConnectionLogSessionID(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateConnectionLogSessionID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateConnectionLogSessionID").Inc()
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateCryptoKeyDeletesAt(ctx, arg)
|
||||
@@ -4285,7 +4397,7 @@ func (m queryMetricsStore) UpsertWorkspaceApp(ctx context.Context, arg database.
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) {
|
||||
func (m queryMetricsStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (database.UpsertWorkspaceAppAuditSessionRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpsertWorkspaceAppAuditSession(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpsertWorkspaceAppAuditSession").Observe(time.Since(start).Seconds())
|
||||
|
||||
@@ -276,6 +276,36 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(ctx any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), ctx)
|
||||
}
|
||||
|
||||
// CloseConnectionLogsAndCreateSessions mocks base method.
|
||||
func (m *MockStore) CloseConnectionLogsAndCreateSessions(ctx context.Context, arg database.CloseConnectionLogsAndCreateSessionsParams) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CloseConnectionLogsAndCreateSessions", ctx, arg)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CloseConnectionLogsAndCreateSessions indicates an expected call of CloseConnectionLogsAndCreateSessions.
|
||||
func (mr *MockStoreMockRecorder) CloseConnectionLogsAndCreateSessions(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseConnectionLogsAndCreateSessions", reflect.TypeOf((*MockStore)(nil).CloseConnectionLogsAndCreateSessions), ctx, arg)
|
||||
}
|
||||
|
||||
// CloseOpenAgentConnectionLogsForWorkspace mocks base method.
|
||||
func (m *MockStore) CloseOpenAgentConnectionLogsForWorkspace(ctx context.Context, arg database.CloseOpenAgentConnectionLogsForWorkspaceParams) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CloseOpenAgentConnectionLogsForWorkspace", ctx, arg)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CloseOpenAgentConnectionLogsForWorkspace indicates an expected call of CloseOpenAgentConnectionLogsForWorkspace.
|
||||
func (mr *MockStoreMockRecorder) CloseOpenAgentConnectionLogsForWorkspace(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseOpenAgentConnectionLogsForWorkspace", reflect.TypeOf((*MockStore)(nil).CloseOpenAgentConnectionLogsForWorkspace), ctx, arg)
|
||||
}
|
||||
|
||||
// CountAIBridgeInterceptions mocks base method.
|
||||
func (m *MockStore) CountAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -366,6 +396,21 @@ func (mr *MockStoreMockRecorder) CountConnectionLogs(ctx, arg any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountConnectionLogs", reflect.TypeOf((*MockStore)(nil).CountConnectionLogs), ctx, arg)
|
||||
}
|
||||
|
||||
// CountGlobalWorkspaceSessions mocks base method.
|
||||
func (m *MockStore) CountGlobalWorkspaceSessions(ctx context.Context, arg database.CountGlobalWorkspaceSessionsParams) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CountGlobalWorkspaceSessions", ctx, arg)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CountGlobalWorkspaceSessions indicates an expected call of CountGlobalWorkspaceSessions.
|
||||
func (mr *MockStoreMockRecorder) CountGlobalWorkspaceSessions(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountGlobalWorkspaceSessions", reflect.TypeOf((*MockStore)(nil).CountGlobalWorkspaceSessions), ctx, arg)
|
||||
}
|
||||
|
||||
// CountInProgressPrebuilds mocks base method.
|
||||
func (m *MockStore) CountInProgressPrebuilds(ctx context.Context) ([]database.CountInProgressPrebuildsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -411,6 +456,21 @@ func (mr *MockStoreMockRecorder) CountUnreadInboxNotificationsByUserID(ctx, user
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountUnreadInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).CountUnreadInboxNotificationsByUserID), ctx, userID)
|
||||
}
|
||||
|
||||
// CountWorkspaceSessions mocks base method.
|
||||
func (m *MockStore) CountWorkspaceSessions(ctx context.Context, arg database.CountWorkspaceSessionsParams) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CountWorkspaceSessions", ctx, arg)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CountWorkspaceSessions indicates an expected call of CountWorkspaceSessions.
|
||||
func (mr *MockStoreMockRecorder) CountWorkspaceSessions(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountWorkspaceSessions", reflect.TypeOf((*MockStore)(nil).CountWorkspaceSessions), ctx, arg)
|
||||
}
|
||||
|
||||
// CreateUserSecret mocks base method.
|
||||
func (m *MockStore) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1199,6 +1259,21 @@ func (mr *MockStoreMockRecorder) FindMatchingPresetID(ctx, arg any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindMatchingPresetID", reflect.TypeOf((*MockStore)(nil).FindMatchingPresetID), ctx, arg)
|
||||
}
|
||||
|
||||
// FindOrCreateSessionForDisconnect mocks base method.
|
||||
func (m *MockStore) FindOrCreateSessionForDisconnect(ctx context.Context, arg database.FindOrCreateSessionForDisconnectParams) (any, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "FindOrCreateSessionForDisconnect", ctx, arg)
|
||||
ret0, _ := ret[0].(any)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// FindOrCreateSessionForDisconnect indicates an expected call of FindOrCreateSessionForDisconnect.
|
||||
func (mr *MockStoreMockRecorder) FindOrCreateSessionForDisconnect(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOrCreateSessionForDisconnect", reflect.TypeOf((*MockStore)(nil).FindOrCreateSessionForDisconnect), ctx, arg)
|
||||
}
|
||||
|
||||
// GetAIBridgeInterceptionByID mocks base method.
|
||||
func (m *MockStore) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (database.AIBridgeInterception, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1409,6 +1484,21 @@ func (mr *MockStoreMockRecorder) GetAllTailnetCoordinators(ctx any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTailnetCoordinators", reflect.TypeOf((*MockStore)(nil).GetAllTailnetCoordinators), ctx)
|
||||
}
|
||||
|
||||
// GetAllTailnetPeeringEventsByPeerID mocks base method.
|
||||
func (m *MockStore) GetAllTailnetPeeringEventsByPeerID(ctx context.Context, srcPeerID uuid.NullUUID) ([]database.TailnetPeeringEvent, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAllTailnetPeeringEventsByPeerID", ctx, srcPeerID)
|
||||
ret0, _ := ret[0].([]database.TailnetPeeringEvent)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAllTailnetPeeringEventsByPeerID indicates an expected call of GetAllTailnetPeeringEventsByPeerID.
|
||||
func (mr *MockStoreMockRecorder) GetAllTailnetPeeringEventsByPeerID(ctx, srcPeerID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTailnetPeeringEventsByPeerID", reflect.TypeOf((*MockStore)(nil).GetAllTailnetPeeringEventsByPeerID), ctx, srcPeerID)
|
||||
}
|
||||
|
||||
// GetAllTailnetPeers mocks base method.
|
||||
func (m *MockStore) GetAllTailnetPeers(ctx context.Context) ([]database.TailnetPeer, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1649,6 +1739,36 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx,
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspacesAndAgentsByOwnerID), ctx, ownerID, prepared)
|
||||
}
|
||||
|
||||
// GetConnectionLogByConnectionID mocks base method.
|
||||
func (m *MockStore) GetConnectionLogByConnectionID(ctx context.Context, arg database.GetConnectionLogByConnectionIDParams) (database.ConnectionLog, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetConnectionLogByConnectionID", ctx, arg)
|
||||
ret0, _ := ret[0].(database.ConnectionLog)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetConnectionLogByConnectionID indicates an expected call of GetConnectionLogByConnectionID.
|
||||
func (mr *MockStoreMockRecorder) GetConnectionLogByConnectionID(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConnectionLogByConnectionID", reflect.TypeOf((*MockStore)(nil).GetConnectionLogByConnectionID), ctx, arg)
|
||||
}
|
||||
|
||||
// GetConnectionLogsBySessionIDs mocks base method.
|
||||
func (m *MockStore) GetConnectionLogsBySessionIDs(ctx context.Context, sessionIds []uuid.UUID) ([]database.ConnectionLog, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetConnectionLogsBySessionIDs", ctx, sessionIds)
|
||||
ret0, _ := ret[0].([]database.ConnectionLog)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetConnectionLogsBySessionIDs indicates an expected call of GetConnectionLogsBySessionIDs.
|
||||
func (mr *MockStoreMockRecorder) GetConnectionLogsBySessionIDs(ctx, sessionIds any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConnectionLogsBySessionIDs", reflect.TypeOf((*MockStore)(nil).GetConnectionLogsBySessionIDs), ctx, sessionIds)
|
||||
}
|
||||
|
||||
// GetConnectionLogsOffset mocks base method.
|
||||
func (m *MockStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -2009,6 +2129,21 @@ func (mr *MockStoreMockRecorder) GetGitSSHKey(ctx, userID any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGitSSHKey", reflect.TypeOf((*MockStore)(nil).GetGitSSHKey), ctx, userID)
|
||||
}
|
||||
|
||||
// GetGlobalWorkspaceSessionsOffset mocks base method.
|
||||
func (m *MockStore) GetGlobalWorkspaceSessionsOffset(ctx context.Context, arg database.GetGlobalWorkspaceSessionsOffsetParams) ([]database.GetGlobalWorkspaceSessionsOffsetRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetGlobalWorkspaceSessionsOffset", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetGlobalWorkspaceSessionsOffsetRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetGlobalWorkspaceSessionsOffset indicates an expected call of GetGlobalWorkspaceSessionsOffset.
|
||||
func (mr *MockStoreMockRecorder) GetGlobalWorkspaceSessionsOffset(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGlobalWorkspaceSessionsOffset", reflect.TypeOf((*MockStore)(nil).GetGlobalWorkspaceSessionsOffset), ctx, arg)
|
||||
}
|
||||
|
||||
// GetGroupByID mocks base method.
|
||||
func (m *MockStore) GetGroupByID(ctx context.Context, id uuid.UUID) (database.Group, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -2564,6 +2699,21 @@ func (mr *MockStoreMockRecorder) GetOAuthSigningKey(ctx any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthSigningKey", reflect.TypeOf((*MockStore)(nil).GetOAuthSigningKey), ctx)
|
||||
}
|
||||
|
||||
// GetOngoingAgentConnectionsLast24h mocks base method.
|
||||
func (m *MockStore) GetOngoingAgentConnectionsLast24h(ctx context.Context, arg database.GetOngoingAgentConnectionsLast24hParams) ([]database.GetOngoingAgentConnectionsLast24hRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetOngoingAgentConnectionsLast24h", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetOngoingAgentConnectionsLast24hRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetOngoingAgentConnectionsLast24h indicates an expected call of GetOngoingAgentConnectionsLast24h.
|
||||
func (mr *MockStoreMockRecorder) GetOngoingAgentConnectionsLast24h(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOngoingAgentConnectionsLast24h", reflect.TypeOf((*MockStore)(nil).GetOngoingAgentConnectionsLast24h), ctx, arg)
|
||||
}
|
||||
|
||||
// GetOrganizationByID mocks base method.
|
||||
func (m *MockStore) GetOrganizationByID(ctx context.Context, id uuid.UUID) (database.Organization, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -3209,6 +3359,21 @@ func (mr *MockStoreMockRecorder) GetTailnetTunnelPeerBindings(ctx, srcID any) *g
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTailnetTunnelPeerBindings", reflect.TypeOf((*MockStore)(nil).GetTailnetTunnelPeerBindings), ctx, srcID)
|
||||
}
|
||||
|
||||
// GetTailnetTunnelPeerBindingsByDstID mocks base method.
|
||||
func (m *MockStore) GetTailnetTunnelPeerBindingsByDstID(ctx context.Context, dstID uuid.UUID) ([]database.GetTailnetTunnelPeerBindingsByDstIDRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTailnetTunnelPeerBindingsByDstID", ctx, dstID)
|
||||
ret0, _ := ret[0].([]database.GetTailnetTunnelPeerBindingsByDstIDRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTailnetTunnelPeerBindingsByDstID indicates an expected call of GetTailnetTunnelPeerBindingsByDstID.
|
||||
func (mr *MockStoreMockRecorder) GetTailnetTunnelPeerBindingsByDstID(ctx, dstID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTailnetTunnelPeerBindingsByDstID", reflect.TypeOf((*MockStore)(nil).GetTailnetTunnelPeerBindingsByDstID), ctx, dstID)
|
||||
}
|
||||
|
||||
// GetTailnetTunnelPeerIDs mocks base method.
|
||||
func (m *MockStore) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]database.GetTailnetTunnelPeerIDsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -4844,6 +5009,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceResourcesCreatedAfter(ctx, createdA
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceResourcesCreatedAfter", reflect.TypeOf((*MockStore)(nil).GetWorkspaceResourcesCreatedAfter), ctx, createdAt)
|
||||
}
|
||||
|
||||
// GetWorkspaceSessionsOffset mocks base method.
|
||||
func (m *MockStore) GetWorkspaceSessionsOffset(ctx context.Context, arg database.GetWorkspaceSessionsOffsetParams) ([]database.GetWorkspaceSessionsOffsetRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetWorkspaceSessionsOffset", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetWorkspaceSessionsOffsetRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetWorkspaceSessionsOffset indicates an expected call of GetWorkspaceSessionsOffset.
|
||||
func (mr *MockStoreMockRecorder) GetWorkspaceSessionsOffset(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceSessionsOffset", reflect.TypeOf((*MockStore)(nil).GetWorkspaceSessionsOffset), ctx, arg)
|
||||
}
|
||||
|
||||
// GetWorkspaceUniqueOwnerCountByTemplateIDs mocks base method.
|
||||
func (m *MockStore) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]database.GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -5469,6 +5649,20 @@ func (mr *MockStoreMockRecorder) InsertReplica(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertReplica", reflect.TypeOf((*MockStore)(nil).InsertReplica), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertTailnetPeeringEvent mocks base method.
|
||||
func (m *MockStore) InsertTailnetPeeringEvent(ctx context.Context, arg database.InsertTailnetPeeringEventParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InsertTailnetPeeringEvent", ctx, arg)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// InsertTailnetPeeringEvent indicates an expected call of InsertTailnetPeeringEvent.
|
||||
func (mr *MockStoreMockRecorder) InsertTailnetPeeringEvent(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertTailnetPeeringEvent", reflect.TypeOf((*MockStore)(nil).InsertTailnetPeeringEvent), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertTask mocks base method.
|
||||
func (m *MockStore) InsertTask(ctx context.Context, arg database.InsertTaskParams) (database.TaskTable, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -6380,6 +6574,20 @@ func (mr *MockStoreMockRecorder) UpdateAPIKeyByID(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeyByID", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeyByID), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateConnectionLogSessionID mocks base method.
|
||||
func (m *MockStore) UpdateConnectionLogSessionID(ctx context.Context, arg database.UpdateConnectionLogSessionIDParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateConnectionLogSessionID", ctx, arg)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpdateConnectionLogSessionID indicates an expected call of UpdateConnectionLogSessionID.
|
||||
func (mr *MockStoreMockRecorder) UpdateConnectionLogSessionID(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateConnectionLogSessionID", reflect.TypeOf((*MockStore)(nil).UpdateConnectionLogSessionID), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateCryptoKeyDeletesAt mocks base method.
|
||||
func (m *MockStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -7993,10 +8201,10 @@ func (mr *MockStoreMockRecorder) UpsertWorkspaceApp(ctx, arg any) *gomock.Call {
|
||||
}
|
||||
|
||||
// UpsertWorkspaceAppAuditSession mocks base method.
|
||||
func (m *MockStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) {
|
||||
func (m *MockStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (database.UpsertWorkspaceAppAuditSessionRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpsertWorkspaceAppAuditSession", ctx, arg)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret0, _ := ret[0].(database.UpsertWorkspaceAppAuditSessionRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
Generated
+61
-3
@@ -271,7 +271,8 @@ CREATE TYPE connection_type AS ENUM (
|
||||
'jetbrains',
|
||||
'reconnecting_pty',
|
||||
'workspace_app',
|
||||
'port_forwarding'
|
||||
'port_forwarding',
|
||||
'system'
|
||||
);
|
||||
|
||||
CREATE TYPE cors_behavior AS ENUM (
|
||||
@@ -1015,6 +1016,11 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TABLE agent_peering_ids (
|
||||
agent_id uuid NOT NULL,
|
||||
peering_id bytea NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE aibridge_interceptions (
|
||||
id uuid NOT NULL,
|
||||
initiator_id uuid NOT NULL,
|
||||
@@ -1159,7 +1165,12 @@ CREATE TABLE connection_logs (
|
||||
slug_or_port text,
|
||||
connection_id uuid,
|
||||
disconnect_time timestamp with time zone,
|
||||
disconnect_reason text
|
||||
disconnect_reason text,
|
||||
agent_id uuid,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
session_id uuid,
|
||||
client_hostname text,
|
||||
short_description text
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN connection_logs.code IS 'Either the HTTP status code of the web request, or the exit code of an SSH connection. For non-web connections, this is Null until we receive a disconnect event for the same connection_id.';
|
||||
@@ -1176,6 +1187,8 @@ COMMENT ON COLUMN connection_logs.disconnect_time IS 'The time the connection wa
|
||||
|
||||
COMMENT ON COLUMN connection_logs.disconnect_reason IS 'The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.';
|
||||
|
||||
COMMENT ON COLUMN connection_logs.updated_at IS 'Last time this connection log was confirmed active. For agent connections, equals connect_time. For web connections, bumped while the session is active.';
|
||||
|
||||
CREATE TABLE crypto_keys (
|
||||
feature crypto_key_feature NOT NULL,
|
||||
sequence integer NOT NULL,
|
||||
@@ -1771,6 +1784,15 @@ CREATE UNLOGGED TABLE tailnet_coordinators (
|
||||
|
||||
COMMENT ON TABLE tailnet_coordinators IS 'We keep this separate from replicas in case we need to break the coordinator out into its own service';
|
||||
|
||||
CREATE TABLE tailnet_peering_events (
|
||||
peering_id bytea NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
src_peer_id uuid,
|
||||
dst_peer_id uuid,
|
||||
node bytea,
|
||||
occurred_at timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNLOGGED TABLE tailnet_peers (
|
||||
id uuid NOT NULL,
|
||||
coordinator_id uuid NOT NULL,
|
||||
@@ -2604,7 +2626,8 @@ CREATE UNLOGGED TABLE workspace_app_audit_sessions (
|
||||
status_code integer NOT NULL,
|
||||
started_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
id uuid NOT NULL
|
||||
id uuid NOT NULL,
|
||||
connection_id uuid
|
||||
);
|
||||
|
||||
COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to deduplicate audit log entries for workspace apps. While a session is active, the same data will not be logged again. This table does not store historical data.';
|
||||
@@ -2898,6 +2921,18 @@ CREATE SEQUENCE workspace_resource_metadata_id_seq
|
||||
|
||||
ALTER SEQUENCE workspace_resource_metadata_id_seq OWNED BY workspace_resource_metadata.id;
|
||||
|
||||
CREATE TABLE workspace_sessions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
agent_id uuid,
|
||||
ip inet,
|
||||
client_hostname text,
|
||||
short_description text,
|
||||
started_at timestamp with time zone NOT NULL,
|
||||
ended_at timestamp with time zone NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE VIEW workspaces_expanded AS
|
||||
SELECT workspaces.id,
|
||||
workspaces.created_at,
|
||||
@@ -2955,6 +2990,9 @@ ALTER TABLE ONLY workspace_proxies ALTER COLUMN region_id SET DEFAULT nextval('w
|
||||
|
||||
ALTER TABLE ONLY workspace_resource_metadata ALTER COLUMN id SET DEFAULT nextval('workspace_resource_metadata_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY agent_peering_ids
|
||||
ADD CONSTRAINT agent_peering_ids_pkey PRIMARY KEY (agent_id, peering_id);
|
||||
|
||||
ALTER TABLE ONLY workspace_agent_stats
|
||||
ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -3261,6 +3299,9 @@ ALTER TABLE ONLY workspace_resource_metadata
|
||||
ALTER TABLE ONLY workspace_resources
|
||||
ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY workspace_sessions
|
||||
ADD CONSTRAINT workspace_sessions_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY workspaces
|
||||
ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -3312,6 +3353,8 @@ COMMENT ON INDEX idx_connection_logs_connection_id_workspace_id_agent_name IS 'C
|
||||
|
||||
CREATE INDEX idx_connection_logs_organization_id ON connection_logs USING btree (organization_id);
|
||||
|
||||
CREATE INDEX idx_connection_logs_session ON connection_logs USING btree (session_id) WHERE (session_id IS NOT NULL);
|
||||
|
||||
CREATE INDEX idx_connection_logs_workspace_id ON connection_logs USING btree (workspace_id);
|
||||
|
||||
CREATE INDEX idx_connection_logs_workspace_owner_id ON connection_logs USING btree (workspace_owner_id);
|
||||
@@ -3366,6 +3409,12 @@ CREATE INDEX idx_workspace_app_statuses_workspace_id_created_at ON workspace_app
|
||||
|
||||
CREATE INDEX idx_workspace_builds_initiator_id ON workspace_builds USING btree (initiator_id);
|
||||
|
||||
CREATE INDEX idx_workspace_sessions_hostname_lookup ON workspace_sessions USING btree (workspace_id, client_hostname, started_at) WHERE (client_hostname IS NOT NULL);
|
||||
|
||||
CREATE INDEX idx_workspace_sessions_ip_lookup ON workspace_sessions USING btree (workspace_id, ip, started_at) WHERE ((ip IS NOT NULL) AND (client_hostname IS NULL));
|
||||
|
||||
CREATE INDEX idx_workspace_sessions_workspace ON workspace_sessions USING btree (workspace_id, started_at DESC);
|
||||
|
||||
CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
|
||||
|
||||
CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true);
|
||||
@@ -3553,6 +3602,9 @@ ALTER TABLE ONLY api_keys
|
||||
ALTER TABLE ONLY connection_logs
|
||||
ADD CONSTRAINT connection_logs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY connection_logs
|
||||
ADD CONSTRAINT connection_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES workspace_sessions(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY connection_logs
|
||||
ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
|
||||
|
||||
@@ -3826,6 +3878,12 @@ ALTER TABLE ONLY workspace_resource_metadata
|
||||
ALTER TABLE ONLY workspace_resources
|
||||
ADD CONSTRAINT workspace_resources_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspace_sessions
|
||||
ADD CONSTRAINT workspace_sessions_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY workspace_sessions
|
||||
ADD CONSTRAINT workspace_sessions_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspaces
|
||||
ADD CONSTRAINT workspaces_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const (
|
||||
ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
|
||||
ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyConnectionLogsOrganizationID ForeignKeyConstraint = "connection_logs_organization_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
ForeignKeyConnectionLogsSessionID ForeignKeyConstraint = "connection_logs_session_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES workspace_sessions(id) ON DELETE SET NULL;
|
||||
ForeignKeyConnectionLogsWorkspaceID ForeignKeyConstraint = "connection_logs_workspace_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
|
||||
ForeignKeyConnectionLogsWorkspaceOwnerID ForeignKeyConstraint = "connection_logs_workspace_owner_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_owner_id_fkey FOREIGN KEY (workspace_owner_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyCryptoKeysSecretKeyID ForeignKeyConstraint = "crypto_keys_secret_key_id_fkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest);
|
||||
@@ -100,6 +101,8 @@ const (
|
||||
ForeignKeyWorkspaceModulesJobID ForeignKeyConstraint = "workspace_modules_job_id_fkey" // ALTER TABLE ONLY workspace_modules ADD CONSTRAINT workspace_modules_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceResourceMetadataWorkspaceResourceID ForeignKeyConstraint = "workspace_resource_metadata_workspace_resource_id_fkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_workspace_resource_id_fkey FOREIGN KEY (workspace_resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceResourcesJobID ForeignKeyConstraint = "workspace_resources_job_id_fkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceSessionsAgentID ForeignKeyConstraint = "workspace_sessions_agent_id_fkey" // ALTER TABLE ONLY workspace_sessions ADD CONSTRAINT workspace_sessions_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE SET NULL;
|
||||
ForeignKeyWorkspaceSessionsWorkspaceID ForeignKeyConstraint = "workspace_sessions_workspace_id_fkey" // ALTER TABLE ONLY workspace_sessions ADD CONSTRAINT workspace_sessions_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspacesOrganizationID ForeignKeyConstraint = "workspaces_organization_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT;
|
||||
ForeignKeyWorkspacesOwnerID ForeignKeyConstraint = "workspaces_owner_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||||
ForeignKeyWorkspacesTemplateID ForeignKeyConstraint = "workspaces_template_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE connection_logs
|
||||
DROP COLUMN agent_id;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE connection_logs
|
||||
ADD COLUMN agent_id uuid;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE workspace_app_audit_sessions DROP COLUMN IF EXISTS connection_id;
|
||||
ALTER TABLE connection_logs DROP COLUMN IF EXISTS updated_at;
|
||||
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE workspace_app_audit_sessions
|
||||
ADD COLUMN connection_id uuid;
|
||||
|
||||
ALTER TABLE connection_logs
|
||||
ADD COLUMN updated_at timestamp with time zone;
|
||||
|
||||
UPDATE connection_logs SET updated_at = connect_time WHERE updated_at IS NULL;
|
||||
|
||||
ALTER TABLE connection_logs
|
||||
ALTER COLUMN updated_at SET NOT NULL,
|
||||
ALTER COLUMN updated_at SET DEFAULT now();
|
||||
|
||||
COMMENT ON COLUMN connection_logs.updated_at IS
|
||||
'Last time this connection log was confirmed active. For agent connections, equals connect_time. For web connections, bumped while the session is active.';
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS tailnet_peering_events;
|
||||
DROP TABLE IF EXISTS agent_peering_ids;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE agent_peering_ids (
|
||||
agent_id uuid NOT NULL,
|
||||
peering_id bytea NOT NULL,
|
||||
PRIMARY KEY (agent_id, peering_id)
|
||||
);
|
||||
|
||||
CREATE TABLE tailnet_peering_events (
|
||||
peering_id bytea NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
src_peer_id uuid,
|
||||
dst_peer_id uuid,
|
||||
node bytea,
|
||||
occurred_at timestamp with time zone NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
DROP INDEX IF EXISTS idx_connection_logs_session;
|
||||
|
||||
ALTER TABLE connection_logs
|
||||
DROP COLUMN IF EXISTS short_description,
|
||||
DROP COLUMN IF EXISTS client_hostname,
|
||||
DROP COLUMN IF EXISTS session_id;
|
||||
|
||||
DROP TABLE IF EXISTS workspace_sessions;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE workspace_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
agent_id uuid REFERENCES workspace_agents(id) ON DELETE SET NULL,
|
||||
ip inet NOT NULL,
|
||||
client_hostname text,
|
||||
short_description text,
|
||||
started_at timestamp with time zone NOT NULL,
|
||||
ended_at timestamp with time zone NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workspace_sessions_workspace ON workspace_sessions (workspace_id, started_at DESC);
|
||||
CREATE INDEX idx_workspace_sessions_lookup ON workspace_sessions (workspace_id, ip, started_at);
|
||||
|
||||
ALTER TABLE connection_logs
|
||||
ADD COLUMN session_id uuid REFERENCES workspace_sessions(id) ON DELETE SET NULL,
|
||||
ADD COLUMN client_hostname text,
|
||||
ADD COLUMN short_description text;
|
||||
|
||||
CREATE INDEX idx_connection_logs_session ON connection_logs (session_id) WHERE session_id IS NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op: PostgreSQL does not support removing enum values.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE connection_type ADD VALUE IF NOT EXISTS 'system';
|
||||
@@ -0,0 +1,6 @@
|
||||
UPDATE workspace_sessions SET ip = '0.0.0.0'::inet WHERE ip IS NULL;
|
||||
ALTER TABLE workspace_sessions ALTER COLUMN ip SET NOT NULL;
|
||||
|
||||
DROP INDEX IF EXISTS idx_workspace_sessions_hostname_lookup;
|
||||
DROP INDEX IF EXISTS idx_workspace_sessions_ip_lookup;
|
||||
CREATE INDEX idx_workspace_sessions_lookup ON workspace_sessions (workspace_id, ip, started_at);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Make workspace_sessions.ip nullable since sessions now group by
|
||||
-- hostname (with IP fallback), and a session may span multiple IPs.
|
||||
ALTER TABLE workspace_sessions ALTER COLUMN ip DROP NOT NULL;
|
||||
|
||||
-- Replace the IP-based lookup index with hostname-based indexes
|
||||
-- to support the new grouping logic.
|
||||
DROP INDEX IF EXISTS idx_workspace_sessions_lookup;
|
||||
CREATE INDEX idx_workspace_sessions_hostname_lookup
|
||||
ON workspace_sessions (workspace_id, client_hostname, started_at)
|
||||
WHERE client_hostname IS NOT NULL;
|
||||
CREATE INDEX idx_workspace_sessions_ip_lookup
|
||||
ON workspace_sessions (workspace_id, ip, started_at)
|
||||
WHERE ip IS NOT NULL AND client_hostname IS NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
INSERT INTO agent_peering_ids
|
||||
(agent_id, peering_id)
|
||||
VALUES (
|
||||
'c0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
'\xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
|
||||
);
|
||||
|
||||
INSERT INTO tailnet_peering_events
|
||||
(peering_id, event_type, src_peer_id, dst_peer_id, node, occurred_at)
|
||||
VALUES (
|
||||
'\xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
|
||||
'added_tunnel',
|
||||
'c0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
'b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
'a fake protobuf byte string',
|
||||
'2025-01-15 10:23:54+00'
|
||||
);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
INSERT INTO workspace_sessions
|
||||
(id, workspace_id, agent_id, ip, started_at, ended_at, created_at)
|
||||
VALUES (
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
'3a9a1feb-e89d-457c-9d53-ac751b198ebe',
|
||||
'5f8e48e4-1304-45bd-b91a-ab12c8bfc20f',
|
||||
'127.0.0.1',
|
||||
'2025-01-01 10:00:00+00',
|
||||
'2025-01-01 11:00:00+00',
|
||||
'2025-01-01 11:00:00+00'
|
||||
);
|
||||
@@ -689,6 +689,11 @@ func (q *sqlQuerier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg
|
||||
&i.ConnectionLog.ConnectionID,
|
||||
&i.ConnectionLog.DisconnectTime,
|
||||
&i.ConnectionLog.DisconnectReason,
|
||||
&i.ConnectionLog.AgentID,
|
||||
&i.ConnectionLog.UpdatedAt,
|
||||
&i.ConnectionLog.SessionID,
|
||||
&i.ConnectionLog.ClientHostname,
|
||||
&i.ConnectionLog.ShortDescription,
|
||||
&i.UserUsername,
|
||||
&i.UserName,
|
||||
&i.UserEmail,
|
||||
|
||||
@@ -1101,6 +1101,7 @@ const (
|
||||
ConnectionTypeReconnectingPty ConnectionType = "reconnecting_pty"
|
||||
ConnectionTypeWorkspaceApp ConnectionType = "workspace_app"
|
||||
ConnectionTypePortForwarding ConnectionType = "port_forwarding"
|
||||
ConnectionTypeSystem ConnectionType = "system"
|
||||
)
|
||||
|
||||
func (e *ConnectionType) Scan(src interface{}) error {
|
||||
@@ -1145,7 +1146,8 @@ func (e ConnectionType) Valid() bool {
|
||||
ConnectionTypeJetbrains,
|
||||
ConnectionTypeReconnectingPty,
|
||||
ConnectionTypeWorkspaceApp,
|
||||
ConnectionTypePortForwarding:
|
||||
ConnectionTypePortForwarding,
|
||||
ConnectionTypeSystem:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -1159,6 +1161,7 @@ func AllConnectionTypeValues() []ConnectionType {
|
||||
ConnectionTypeReconnectingPty,
|
||||
ConnectionTypeWorkspaceApp,
|
||||
ConnectionTypePortForwarding,
|
||||
ConnectionTypeSystem,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3702,6 +3705,11 @@ type APIKey struct {
|
||||
AllowList AllowList `db:"allow_list" json:"allow_list"`
|
||||
}
|
||||
|
||||
type AgentPeeringID struct {
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
PeeringID []byte `db:"peering_id" json:"peering_id"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Time time.Time `db:"time" json:"time"`
|
||||
@@ -3762,6 +3770,12 @@ type ConnectionLog struct {
|
||||
DisconnectTime sql.NullTime `db:"disconnect_time" json:"disconnect_time"`
|
||||
// The reason the connection was closed. Null for web connections. For other connections, this is null until we receive a disconnect event for the same connection_id.
|
||||
DisconnectReason sql.NullString `db:"disconnect_reason" json:"disconnect_reason"`
|
||||
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
|
||||
// Last time this connection log was confirmed active. For agent connections, equals connect_time. For web connections, bumped while the session is active.
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
SessionID uuid.NullUUID `db:"session_id" json:"session_id"`
|
||||
ClientHostname sql.NullString `db:"client_hostname" json:"client_hostname"`
|
||||
ShortDescription sql.NullString `db:"short_description" json:"short_description"`
|
||||
}
|
||||
|
||||
type CryptoKey struct {
|
||||
@@ -4228,6 +4242,15 @@ type TailnetPeer struct {
|
||||
Status TailnetStatus `db:"status" json:"status"`
|
||||
}
|
||||
|
||||
type TailnetPeeringEvent struct {
|
||||
PeeringID []byte `db:"peering_id" json:"peering_id"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
SrcPeerID uuid.NullUUID `db:"src_peer_id" json:"src_peer_id"`
|
||||
DstPeerID uuid.NullUUID `db:"dst_peer_id" json:"dst_peer_id"`
|
||||
Node []byte `db:"node" json:"node"`
|
||||
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
|
||||
}
|
||||
|
||||
type TailnetTunnel struct {
|
||||
CoordinatorID uuid.UUID `db:"coordinator_id" json:"coordinator_id"`
|
||||
SrcID uuid.UUID `db:"src_id" json:"src_id"`
|
||||
@@ -4933,8 +4956,9 @@ type WorkspaceAppAuditSession struct {
|
||||
// The time the user started the session.
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
// The time the session was last updated.
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ConnectionID uuid.NullUUID `db:"connection_id" json:"connection_id"`
|
||||
}
|
||||
|
||||
// A record of workspace app usage statistics
|
||||
@@ -5109,6 +5133,18 @@ type WorkspaceResourceMetadatum struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
type WorkspaceSession struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
|
||||
Ip pqtype.Inet `db:"ip" json:"ip"`
|
||||
ClientHostname sql.NullString `db:"client_hostname" json:"client_hostname"`
|
||||
ShortDescription sql.NullString `db:"short_description" json:"short_description"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
EndedAt time.Time `db:"ended_at" json:"ended_at"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type WorkspaceTable struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
|
||||
@@ -68,15 +68,43 @@ type sqlcQuerier interface {
|
||||
CleanTailnetCoordinators(ctx context.Context) error
|
||||
CleanTailnetLostPeers(ctx context.Context) error
|
||||
CleanTailnetTunnels(ctx context.Context) error
|
||||
// Atomically closes open connections and creates sessions grouped by
|
||||
// client_hostname (with IP fallback) and time overlap. Non-system
|
||||
// connections drive session boundaries; system connections attach to
|
||||
// the first overlapping session or get their own if orphaned.
|
||||
//
|
||||
// Processes connections that are still open (disconnect_time IS NULL) OR
|
||||
// already disconnected but not yet assigned to a session (session_id IS
|
||||
// NULL). The latter covers system/tunnel connections whose disconnect is
|
||||
// recorded by dbsink but which have no session-assignment code path.
|
||||
// Phase 1: Group non-system connections by hostname+time overlap.
|
||||
// System connections persist for the entire workspace lifetime and
|
||||
// would create mega-sessions if included in boundary computation.
|
||||
// Check for pre-existing sessions that match by hostname (or IP
|
||||
// fallback) and overlap in time, to avoid duplicates from the race
|
||||
// with FindOrCreateSessionForDisconnect.
|
||||
// Combine existing and newly created sessions.
|
||||
// Phase 2: Assign system connections to the earliest overlapping
|
||||
// primary session. First check sessions from this batch, then fall
|
||||
// back to pre-existing workspace_sessions.
|
||||
// Also match system connections to pre-existing sessions (created
|
||||
// by FindOrCreateSessionForDisconnect) that aren't in this batch.
|
||||
// Create sessions for orphaned system connections (no overlapping
|
||||
// primary session) that have an IP.
|
||||
// Combine all session sources for the final UPDATE.
|
||||
CloseConnectionLogsAndCreateSessions(ctx context.Context, arg CloseConnectionLogsAndCreateSessionsParams) (int64, error)
|
||||
CloseOpenAgentConnectionLogsForWorkspace(ctx context.Context, arg CloseOpenAgentConnectionLogsForWorkspaceParams) (int64, error)
|
||||
CountAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams) (int64, error)
|
||||
CountAuditLogs(ctx context.Context, arg CountAuditLogsParams) (int64, error)
|
||||
CountConnectionLogs(ctx context.Context, arg CountConnectionLogsParams) (int64, error)
|
||||
CountGlobalWorkspaceSessions(ctx context.Context, arg CountGlobalWorkspaceSessionsParams) (int64, error)
|
||||
// CountInProgressPrebuilds returns the number of in-progress prebuilds, grouped by preset ID and transition.
|
||||
// Prebuild considered in-progress if it's in the "pending", "starting", "stopping", or "deleting" state.
|
||||
CountInProgressPrebuilds(ctx context.Context) ([]CountInProgressPrebuildsRow, error)
|
||||
// CountPendingNonActivePrebuilds returns the number of pending prebuilds for non-active template versions
|
||||
CountPendingNonActivePrebuilds(ctx context.Context) ([]CountPendingNonActivePrebuildsRow, error)
|
||||
CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||
CountWorkspaceSessions(ctx context.Context, arg CountWorkspaceSessionsParams) (int64, error)
|
||||
CreateUserSecret(ctx context.Context, arg CreateUserSecretParams) (UserSecret, error)
|
||||
CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error)
|
||||
DeleteAPIKeyByID(ctx context.Context, id string) error
|
||||
@@ -161,6 +189,11 @@ type sqlcQuerier interface {
|
||||
// The query finds presets where all preset parameters are present in the provided parameters,
|
||||
// and returns the preset with the most parameters (largest subset).
|
||||
FindMatchingPresetID(ctx context.Context, arg FindMatchingPresetIDParams) (uuid.UUID, error)
|
||||
// Find existing session within time window, or create new one.
|
||||
// Uses advisory lock to prevent duplicate sessions from concurrent disconnects.
|
||||
// Groups by client_hostname (with IP fallback) to match the live session
|
||||
// grouping in mergeWorkspaceConnectionsIntoSessions.
|
||||
FindOrCreateSessionForDisconnect(ctx context.Context, arg FindOrCreateSessionForDisconnectParams) (interface{}, error)
|
||||
GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (AIBridgeInterception, error)
|
||||
GetAIBridgeInterceptions(ctx context.Context) ([]AIBridgeInterception, error)
|
||||
GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeTokenUsage, error)
|
||||
@@ -177,6 +210,7 @@ type sqlcQuerier interface {
|
||||
GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error)
|
||||
// For PG Coordinator HTMLDebug
|
||||
GetAllTailnetCoordinators(ctx context.Context) ([]TailnetCoordinator, error)
|
||||
GetAllTailnetPeeringEventsByPeerID(ctx context.Context, srcPeerID uuid.NullUUID) ([]TailnetPeeringEvent, error)
|
||||
GetAllTailnetPeers(ctx context.Context) ([]TailnetPeer, error)
|
||||
GetAllTailnetTunnels(ctx context.Context) ([]TailnetTunnel, error)
|
||||
// Atomic read+delete prevents replicas that flush between a separate read and
|
||||
@@ -199,6 +233,8 @@ type sqlcQuerier interface {
|
||||
// This function returns roles for authorization purposes. Implied member roles
|
||||
// are included.
|
||||
GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error)
|
||||
GetConnectionLogByConnectionID(ctx context.Context, arg GetConnectionLogByConnectionIDParams) (ConnectionLog, error)
|
||||
GetConnectionLogsBySessionIDs(ctx context.Context, sessionIds []uuid.UUID) ([]ConnectionLog, error)
|
||||
GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error)
|
||||
GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error)
|
||||
GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error)
|
||||
@@ -231,6 +267,7 @@ type sqlcQuerier interface {
|
||||
// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
|
||||
GetFilteredInboxNotificationsByUserID(ctx context.Context, arg GetFilteredInboxNotificationsByUserIDParams) ([]InboxNotification, error)
|
||||
GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error)
|
||||
GetGlobalWorkspaceSessionsOffset(ctx context.Context, arg GetGlobalWorkspaceSessionsOffsetParams) ([]GetGlobalWorkspaceSessionsOffsetRow, error)
|
||||
GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error)
|
||||
GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error)
|
||||
GetGroupMembers(ctx context.Context, includeSystem bool) ([]GroupMember, error)
|
||||
@@ -278,6 +315,7 @@ type sqlcQuerier interface {
|
||||
GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error)
|
||||
GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]GetOAuth2ProviderAppsByUserIDRow, error)
|
||||
GetOAuthSigningKey(ctx context.Context) (string, error)
|
||||
GetOngoingAgentConnectionsLast24h(ctx context.Context, arg GetOngoingAgentConnectionsLast24hParams) ([]GetOngoingAgentConnectionsLast24hRow, error)
|
||||
GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error)
|
||||
GetOrganizationByName(ctx context.Context, arg GetOrganizationByNameParams) (Organization, error)
|
||||
GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error)
|
||||
@@ -354,6 +392,7 @@ type sqlcQuerier interface {
|
||||
GetRuntimeConfig(ctx context.Context, key string) (string, error)
|
||||
GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]TailnetPeer, error)
|
||||
GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerBindingsRow, error)
|
||||
GetTailnetTunnelPeerBindingsByDstID(ctx context.Context, dstID uuid.UUID) ([]GetTailnetTunnelPeerBindingsByDstIDRow, error)
|
||||
GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerIDsRow, error)
|
||||
GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error)
|
||||
GetTaskByOwnerIDAndName(ctx context.Context, arg GetTaskByOwnerIDAndNameParams) (Task, error)
|
||||
@@ -533,6 +572,7 @@ type sqlcQuerier interface {
|
||||
GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error)
|
||||
GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResource, error)
|
||||
GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error)
|
||||
GetWorkspaceSessionsOffset(ctx context.Context, arg GetWorkspaceSessionsOffsetParams) ([]GetWorkspaceSessionsOffsetRow, error)
|
||||
GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error)
|
||||
// build_params is used to filter by build parameters if present.
|
||||
// It has to be a CTE because the set returning function 'unnest' cannot
|
||||
@@ -584,6 +624,7 @@ type sqlcQuerier interface {
|
||||
InsertProvisionerJobTimings(ctx context.Context, arg InsertProvisionerJobTimingsParams) ([]ProvisionerJobTiming, error)
|
||||
InsertProvisionerKey(ctx context.Context, arg InsertProvisionerKeyParams) (ProvisionerKey, error)
|
||||
InsertReplica(ctx context.Context, arg InsertReplicaParams) (Replica, error)
|
||||
InsertTailnetPeeringEvent(ctx context.Context, arg InsertTailnetPeeringEventParams) error
|
||||
InsertTask(ctx context.Context, arg InsertTaskParams) (TaskTable, error)
|
||||
InsertTelemetryItemIfNotExists(ctx context.Context, arg InsertTelemetryItemIfNotExistsParams) error
|
||||
// Inserts a new lock row into the telemetry_locks table. Replicas should call
|
||||
@@ -670,6 +711,8 @@ type sqlcQuerier interface {
|
||||
UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error
|
||||
UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error)
|
||||
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
|
||||
// Links a connection log row to its workspace session.
|
||||
UpdateConnectionLogSessionID(ctx context.Context, arg UpdateConnectionLogSessionIDParams) error
|
||||
UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error)
|
||||
UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error)
|
||||
UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error)
|
||||
@@ -799,10 +842,11 @@ type sqlcQuerier interface {
|
||||
UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error)
|
||||
UpsertWorkspaceApp(ctx context.Context, arg UpsertWorkspaceAppParams) (WorkspaceApp, error)
|
||||
//
|
||||
// The returned boolean, new_or_stale, can be used to deduce if a new session
|
||||
// was started. This means that a new row was inserted (no previous session) or
|
||||
// the updated_at is older than stale interval.
|
||||
UpsertWorkspaceAppAuditSession(ctx context.Context, arg UpsertWorkspaceAppAuditSessionParams) (bool, error)
|
||||
// The returned columns, new_or_stale and connection_id, can be used to deduce
|
||||
// if a new session was started and which connection_id to use. new_or_stale is
|
||||
// true when a new row was inserted (no previous session) or the updated_at is
|
||||
// older than the stale interval.
|
||||
UpsertWorkspaceAppAuditSession(ctx context.Context, arg UpsertWorkspaceAppAuditSessionParams) (UpsertWorkspaceAppAuditSessionRow, error)
|
||||
ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (ValidateGroupIDsRow, error)
|
||||
ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (ValidateUserIDsRow, error)
|
||||
}
|
||||
|
||||
@@ -3081,7 +3081,7 @@ func TestConnectionLogsOffsetFilters(t *testing.T) {
|
||||
params: database.GetConnectionLogsOffsetParams{
|
||||
Status: string(codersdk.ConnectionLogStatusOngoing),
|
||||
},
|
||||
expectedLogIDs: []uuid.UUID{log4.ID},
|
||||
expectedLogIDs: []uuid.UUID{log1.ID, log4.ID},
|
||||
},
|
||||
{
|
||||
name: "StatusCompleted",
|
||||
@@ -3308,12 +3308,16 @@ func TestUpsertConnectionLog(t *testing.T) {
|
||||
|
||||
origLog, err := db.UpsertConnectionLog(ctx, connectParams2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, log, origLog, "connect update should be a no-op")
|
||||
// updated_at is always bumped on conflict to track activity.
|
||||
require.True(t, connectTime2.Equal(origLog.UpdatedAt), "expected updated_at %s, got %s", connectTime2, origLog.UpdatedAt)
|
||||
origLog.UpdatedAt = log.UpdatedAt
|
||||
require.Equal(t, log, origLog, "connect update should be a no-op except updated_at")
|
||||
|
||||
// Check that still only one row exists.
|
||||
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
rows[0].ConnectionLog.UpdatedAt = log.UpdatedAt
|
||||
require.Equal(t, log, rows[0].ConnectionLog)
|
||||
})
|
||||
|
||||
@@ -3395,6 +3399,8 @@ func TestUpsertConnectionLog(t *testing.T) {
|
||||
secondRows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, secondRows, 1)
|
||||
// updated_at is always bumped on conflict to track activity.
|
||||
secondRows[0].ConnectionLog.UpdatedAt = firstRows[0].ConnectionLog.UpdatedAt
|
||||
require.Equal(t, firstRows, secondRows)
|
||||
|
||||
// Upsert a disconnection, which should also be a no op
|
||||
|
||||
+985
-47
File diff suppressed because it is too large
Load Diff
@@ -114,9 +114,7 @@ WHERE
|
||||
AND CASE
|
||||
WHEN @status :: text != '' THEN
|
||||
((@status = 'ongoing' AND disconnect_time IS NULL) OR
|
||||
(@status = 'completed' AND disconnect_time IS NOT NULL)) AND
|
||||
-- Exclude web events, since we don't know their close time.
|
||||
"type" NOT IN ('workspace_app', 'port_forwarding')
|
||||
(@status = 'completed' AND disconnect_time IS NOT NULL))
|
||||
ELSE true
|
||||
END
|
||||
-- Authorize Filter clause will be injected below in
|
||||
@@ -229,9 +227,7 @@ WHERE
|
||||
AND CASE
|
||||
WHEN @status :: text != '' THEN
|
||||
((@status = 'ongoing' AND disconnect_time IS NULL) OR
|
||||
(@status = 'completed' AND disconnect_time IS NOT NULL)) AND
|
||||
-- Exclude web events, since we don't know their close time.
|
||||
"type" NOT IN ('workspace_app', 'port_forwarding')
|
||||
(@status = 'completed' AND disconnect_time IS NOT NULL))
|
||||
ELSE true
|
||||
END
|
||||
-- Authorize Filter clause will be injected below in
|
||||
@@ -260,6 +256,7 @@ INSERT INTO connection_logs (
|
||||
workspace_id,
|
||||
workspace_name,
|
||||
agent_name,
|
||||
agent_id,
|
||||
type,
|
||||
code,
|
||||
ip,
|
||||
@@ -268,18 +265,24 @@ INSERT INTO connection_logs (
|
||||
slug_or_port,
|
||||
connection_id,
|
||||
disconnect_reason,
|
||||
disconnect_time
|
||||
disconnect_time,
|
||||
updated_at,
|
||||
session_id,
|
||||
client_hostname,
|
||||
short_description
|
||||
) VALUES
|
||||
($1, @time, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
||||
($1, @time, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
|
||||
-- If we've only received a disconnect event, mark the event as immediately
|
||||
-- closed.
|
||||
CASE
|
||||
WHEN @connection_status::connection_status = 'disconnected'
|
||||
THEN @time :: timestamp with time zone
|
||||
ELSE NULL
|
||||
END)
|
||||
END,
|
||||
@time, $16, $17, $18)
|
||||
ON CONFLICT (connection_id, workspace_id, agent_name)
|
||||
DO UPDATE SET
|
||||
updated_at = @time,
|
||||
-- No-op if the connection is still open.
|
||||
disconnect_time = CASE
|
||||
WHEN @connection_status::connection_status = 'disconnected'
|
||||
@@ -301,5 +304,274 @@ DO UPDATE SET
|
||||
AND connection_logs.code IS NULL
|
||||
THEN EXCLUDED.code
|
||||
ELSE connection_logs.code
|
||||
END
|
||||
END,
|
||||
agent_id = COALESCE(connection_logs.agent_id, EXCLUDED.agent_id)
|
||||
RETURNING *;
|
||||
|
||||
|
||||
-- name: CloseOpenAgentConnectionLogsForWorkspace :execrows
|
||||
UPDATE connection_logs
|
||||
SET
|
||||
disconnect_time = GREATEST(connect_time, @closed_at :: timestamp with time zone),
|
||||
-- Do not overwrite any existing reason.
|
||||
disconnect_reason = COALESCE(disconnect_reason, @reason :: text)
|
||||
WHERE disconnect_time IS NULL
|
||||
AND workspace_id = @workspace_id :: uuid
|
||||
AND type = ANY(@types :: connection_type[]);
|
||||
|
||||
-- name: GetOngoingAgentConnectionsLast24h :many
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
connect_time,
|
||||
organization_id,
|
||||
workspace_owner_id,
|
||||
workspace_id,
|
||||
workspace_name,
|
||||
agent_name,
|
||||
type,
|
||||
ip,
|
||||
code,
|
||||
user_agent,
|
||||
user_id,
|
||||
slug_or_port,
|
||||
connection_id,
|
||||
disconnect_time,
|
||||
disconnect_reason,
|
||||
agent_id,
|
||||
updated_at,
|
||||
session_id,
|
||||
client_hostname,
|
||||
short_description,
|
||||
row_number() OVER (
|
||||
PARTITION BY workspace_id, agent_name
|
||||
ORDER BY connect_time DESC
|
||||
) AS rn
|
||||
FROM
|
||||
connection_logs
|
||||
WHERE
|
||||
workspace_id = ANY(@workspace_ids :: uuid[])
|
||||
AND agent_name = ANY(@agent_names :: text[])
|
||||
AND type = ANY(@types :: connection_type[])
|
||||
AND disconnect_time IS NULL
|
||||
AND (
|
||||
-- Non-web types always included while connected.
|
||||
type NOT IN ('workspace_app', 'port_forwarding')
|
||||
-- Agent-reported web connections have NULL user_agent
|
||||
-- and carry proper disconnect lifecycle tracking.
|
||||
OR user_agent IS NULL
|
||||
-- Proxy-reported web connections (non-NULL user_agent)
|
||||
-- rely on updated_at being bumped on each token refresh.
|
||||
OR updated_at >= @app_active_since :: timestamp with time zone
|
||||
)
|
||||
AND connect_time >= @since :: timestamp with time zone
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
connect_time,
|
||||
organization_id,
|
||||
workspace_owner_id,
|
||||
workspace_id,
|
||||
workspace_name,
|
||||
agent_name,
|
||||
type,
|
||||
ip,
|
||||
code,
|
||||
user_agent,
|
||||
user_id,
|
||||
slug_or_port,
|
||||
connection_id,
|
||||
disconnect_time,
|
||||
disconnect_reason,
|
||||
updated_at,
|
||||
session_id,
|
||||
client_hostname,
|
||||
short_description
|
||||
FROM
|
||||
ranked
|
||||
WHERE
|
||||
rn <= @per_agent_limit
|
||||
ORDER BY
|
||||
workspace_id,
|
||||
agent_name,
|
||||
connect_time DESC;
|
||||
|
||||
-- name: UpdateConnectionLogSessionID :exec
|
||||
-- Links a connection log row to its workspace session.
|
||||
UPDATE connection_logs SET session_id = @session_id WHERE id = @id;
|
||||
|
||||
-- name: CloseConnectionLogsAndCreateSessions :execrows
|
||||
-- Atomically closes open connections and creates sessions grouped by
|
||||
-- client_hostname (with IP fallback) and time overlap. Non-system
|
||||
-- connections drive session boundaries; system connections attach to
|
||||
-- the first overlapping session or get their own if orphaned.
|
||||
--
|
||||
-- Processes connections that are still open (disconnect_time IS NULL) OR
|
||||
-- already disconnected but not yet assigned to a session (session_id IS
|
||||
-- NULL). The latter covers system/tunnel connections whose disconnect is
|
||||
-- recorded by dbsink but which have no session-assignment code path.
|
||||
WITH connections_to_close AS (
|
||||
SELECT id, ip, connect_time, disconnect_time, agent_id,
|
||||
client_hostname, short_description, type
|
||||
FROM connection_logs
|
||||
WHERE (disconnect_time IS NULL OR session_id IS NULL)
|
||||
AND workspace_id = @workspace_id
|
||||
AND type = ANY(@types::connection_type[])
|
||||
),
|
||||
-- Phase 1: Group non-system connections by hostname+time overlap.
|
||||
-- System connections persist for the entire workspace lifetime and
|
||||
-- would create mega-sessions if included in boundary computation.
|
||||
primary_connections AS (
|
||||
SELECT *,
|
||||
COALESCE(client_hostname, host(ip), 'unknown') AS group_key
|
||||
FROM connections_to_close
|
||||
WHERE type != 'system'
|
||||
),
|
||||
ordered AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY group_key ORDER BY connect_time) AS rn,
|
||||
MAX(COALESCE(disconnect_time, @closed_at::timestamptz))
|
||||
OVER (PARTITION BY group_key ORDER BY connect_time
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS running_max_end
|
||||
FROM primary_connections
|
||||
),
|
||||
with_boundaries AS (
|
||||
SELECT *,
|
||||
SUM(CASE
|
||||
WHEN rn = 1 THEN 1
|
||||
WHEN connect_time > running_max_end + INTERVAL '30 minutes' THEN 1
|
||||
ELSE 0
|
||||
END) OVER (PARTITION BY group_key ORDER BY connect_time) AS group_id
|
||||
FROM ordered
|
||||
),
|
||||
session_groups AS (
|
||||
SELECT
|
||||
group_key,
|
||||
group_id,
|
||||
MIN(connect_time) AS started_at,
|
||||
MAX(COALESCE(disconnect_time, @closed_at::timestamptz)) AS ended_at,
|
||||
(array_agg(agent_id ORDER BY connect_time) FILTER (WHERE agent_id IS NOT NULL))[1] AS agent_id,
|
||||
(array_agg(ip ORDER BY connect_time) FILTER (WHERE ip IS NOT NULL))[1] AS ip,
|
||||
(array_agg(client_hostname ORDER BY connect_time) FILTER (WHERE client_hostname IS NOT NULL))[1] AS client_hostname,
|
||||
(array_agg(short_description ORDER BY connect_time) FILTER (WHERE short_description IS NOT NULL))[1] AS short_description
|
||||
FROM with_boundaries
|
||||
GROUP BY group_key, group_id
|
||||
),
|
||||
-- Check for pre-existing sessions that match by hostname (or IP
|
||||
-- fallback) and overlap in time, to avoid duplicates from the race
|
||||
-- with FindOrCreateSessionForDisconnect.
|
||||
existing_sessions AS (
|
||||
SELECT DISTINCT ON (sg.group_key, sg.group_id)
|
||||
sg.group_key, sg.group_id, ws.id AS session_id
|
||||
FROM session_groups sg
|
||||
JOIN workspace_sessions ws
|
||||
ON ws.workspace_id = @workspace_id
|
||||
AND (
|
||||
(sg.client_hostname IS NOT NULL AND ws.client_hostname = sg.client_hostname)
|
||||
OR (sg.client_hostname IS NULL AND sg.ip IS NOT NULL AND ws.ip = sg.ip AND ws.client_hostname IS NULL)
|
||||
)
|
||||
AND sg.started_at <= ws.ended_at + INTERVAL '30 minutes'
|
||||
AND sg.ended_at >= ws.started_at - INTERVAL '30 minutes'
|
||||
ORDER BY sg.group_key, sg.group_id, ws.started_at DESC
|
||||
),
|
||||
new_sessions AS (
|
||||
INSERT INTO workspace_sessions (workspace_id, agent_id, ip, client_hostname, short_description, started_at, ended_at)
|
||||
SELECT @workspace_id, sg.agent_id, sg.ip, sg.client_hostname, sg.short_description, sg.started_at, sg.ended_at
|
||||
FROM session_groups sg
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM existing_sessions es
|
||||
WHERE es.group_key = sg.group_key AND es.group_id = sg.group_id
|
||||
)
|
||||
RETURNING id, ip, started_at
|
||||
),
|
||||
-- Combine existing and newly created sessions.
|
||||
all_sessions AS (
|
||||
SELECT ns.id, sg.group_key, sg.started_at
|
||||
FROM new_sessions ns
|
||||
JOIN session_groups sg
|
||||
ON sg.started_at = ns.started_at
|
||||
AND (sg.ip IS NOT DISTINCT FROM ns.ip)
|
||||
UNION ALL
|
||||
SELECT es.session_id AS id, es.group_key, sg.started_at
|
||||
FROM existing_sessions es
|
||||
JOIN session_groups sg ON es.group_key = sg.group_key AND es.group_id = sg.group_id
|
||||
),
|
||||
-- Phase 2: Assign system connections to the earliest overlapping
|
||||
-- primary session. First check sessions from this batch, then fall
|
||||
-- back to pre-existing workspace_sessions.
|
||||
system_batch_match AS (
|
||||
SELECT DISTINCT ON (c.id)
|
||||
c.id AS connection_id,
|
||||
alls.id AS session_id,
|
||||
sg.started_at AS session_start
|
||||
FROM connections_to_close c
|
||||
JOIN all_sessions alls ON true
|
||||
JOIN session_groups sg ON alls.group_key = sg.group_key AND alls.started_at = sg.started_at
|
||||
WHERE c.type = 'system'
|
||||
AND COALESCE(c.disconnect_time, @closed_at::timestamptz) >= sg.started_at
|
||||
AND c.connect_time <= sg.ended_at
|
||||
ORDER BY c.id, sg.started_at
|
||||
),
|
||||
-- Also match system connections to pre-existing sessions (created
|
||||
-- by FindOrCreateSessionForDisconnect) that aren't in this batch.
|
||||
system_existing_match AS (
|
||||
SELECT DISTINCT ON (c.id)
|
||||
c.id AS connection_id,
|
||||
ws.id AS session_id
|
||||
FROM connections_to_close c
|
||||
JOIN workspace_sessions ws
|
||||
ON ws.workspace_id = @workspace_id
|
||||
AND COALESCE(c.disconnect_time, @closed_at::timestamptz) >= ws.started_at
|
||||
AND c.connect_time <= ws.ended_at
|
||||
WHERE c.type = 'system'
|
||||
AND NOT EXISTS (SELECT 1 FROM system_batch_match sbm WHERE sbm.connection_id = c.id)
|
||||
ORDER BY c.id, ws.started_at
|
||||
),
|
||||
system_session_match AS (
|
||||
SELECT connection_id, session_id FROM system_batch_match
|
||||
UNION ALL
|
||||
SELECT connection_id, session_id FROM system_existing_match
|
||||
),
|
||||
-- Create sessions for orphaned system connections (no overlapping
|
||||
-- primary session) that have an IP.
|
||||
orphan_system AS (
|
||||
SELECT c.*
|
||||
FROM connections_to_close c
|
||||
LEFT JOIN system_session_match ssm ON ssm.connection_id = c.id
|
||||
WHERE c.type = 'system'
|
||||
AND ssm.connection_id IS NULL
|
||||
AND c.ip IS NOT NULL
|
||||
),
|
||||
orphan_system_sessions AS (
|
||||
INSERT INTO workspace_sessions (workspace_id, agent_id, ip, client_hostname, short_description, started_at, ended_at)
|
||||
SELECT @workspace_id, os.agent_id, os.ip, os.client_hostname, os.short_description,
|
||||
os.connect_time, COALESCE(os.disconnect_time, @closed_at::timestamptz)
|
||||
FROM orphan_system os
|
||||
RETURNING id, ip, started_at
|
||||
),
|
||||
-- Combine all session sources for the final UPDATE.
|
||||
final_sessions AS (
|
||||
-- Primary sessions matched to non-system connections.
|
||||
SELECT wb.id AS connection_id, alls.id AS session_id
|
||||
FROM with_boundaries wb
|
||||
JOIN session_groups sg ON wb.group_key = sg.group_key AND wb.group_id = sg.group_id
|
||||
JOIN all_sessions alls ON sg.group_key = alls.group_key AND sg.started_at = alls.started_at
|
||||
UNION ALL
|
||||
-- System connections matched to primary sessions.
|
||||
SELECT ssm.connection_id, ssm.session_id
|
||||
FROM system_session_match ssm
|
||||
UNION ALL
|
||||
-- Orphaned system connections with their own sessions.
|
||||
SELECT os.id, oss.id
|
||||
FROM orphan_system os
|
||||
JOIN orphan_system_sessions oss ON os.ip = oss.ip AND os.connect_time = oss.started_at
|
||||
)
|
||||
UPDATE connection_logs cl
|
||||
SET
|
||||
disconnect_time = COALESCE(cl.disconnect_time, @closed_at),
|
||||
disconnect_reason = COALESCE(cl.disconnect_reason, @reason),
|
||||
session_id = COALESCE(cl.session_id, fs.session_id)
|
||||
FROM connections_to_close ctc
|
||||
LEFT JOIN final_sessions fs ON ctc.id = fs.connection_id
|
||||
WHERE cl.id = ctc.id;
|
||||
|
||||
|
||||
@@ -126,5 +126,29 @@ SELECT * FROM tailnet_coordinators;
|
||||
-- name: GetAllTailnetPeers :many
|
||||
SELECT * FROM tailnet_peers;
|
||||
|
||||
-- name: GetTailnetTunnelPeerBindingsByDstID :many
|
||||
SELECT tp.id AS peer_id, tp.coordinator_id, tp.updated_at, tp.node, tp.status
|
||||
FROM tailnet_peers tp
|
||||
INNER JOIN tailnet_tunnels tt ON tp.id = tt.src_id
|
||||
WHERE tt.dst_id = @dst_id;
|
||||
|
||||
-- name: GetAllTailnetTunnels :many
|
||||
SELECT * FROM tailnet_tunnels;
|
||||
|
||||
-- name: InsertTailnetPeeringEvent :exec
|
||||
INSERT INTO tailnet_peering_events (
|
||||
peering_id,
|
||||
event_type,
|
||||
src_peer_id,
|
||||
dst_peer_id,
|
||||
node,
|
||||
occurred_at
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6);
|
||||
|
||||
-- name: GetAllTailnetPeeringEventsByPeerID :many
|
||||
SELECT *
|
||||
FROM tailnet_peering_events
|
||||
WHERE src_peer_id = $1 OR dst_peer_id = $1
|
||||
ORDER BY peering_id, occurred_at;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
-- name: UpsertWorkspaceAppAuditSession :one
|
||||
--
|
||||
-- The returned boolean, new_or_stale, can be used to deduce if a new session
|
||||
-- was started. This means that a new row was inserted (no previous session) or
|
||||
-- the updated_at is older than stale interval.
|
||||
-- The returned columns, new_or_stale and connection_id, can be used to deduce
|
||||
-- if a new session was started and which connection_id to use. new_or_stale is
|
||||
-- true when a new row was inserted (no previous session) or the updated_at is
|
||||
-- older than the stale interval.
|
||||
INSERT INTO
|
||||
workspace_app_audit_sessions (
|
||||
id,
|
||||
@@ -14,7 +15,8 @@ INSERT INTO
|
||||
slug_or_port,
|
||||
status_code,
|
||||
started_at,
|
||||
updated_at
|
||||
updated_at,
|
||||
connection_id
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@@ -27,7 +29,8 @@ VALUES
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10
|
||||
$10,
|
||||
$11
|
||||
)
|
||||
ON CONFLICT
|
||||
(agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code)
|
||||
@@ -45,6 +48,12 @@ DO
|
||||
THEN workspace_app_audit_sessions.started_at
|
||||
ELSE EXCLUDED.started_at
|
||||
END,
|
||||
connection_id = CASE
|
||||
WHEN workspace_app_audit_sessions.updated_at > NOW() - (@stale_interval_ms::bigint || ' ms')::interval
|
||||
THEN workspace_app_audit_sessions.connection_id
|
||||
ELSE EXCLUDED.connection_id
|
||||
END,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING
|
||||
id = $1 AS new_or_stale;
|
||||
id = $1 AS new_or_stale,
|
||||
connection_id;
|
||||
|
||||
@@ -399,13 +399,13 @@ WHERE
|
||||
filtered_workspaces fw
|
||||
ORDER BY
|
||||
-- To ensure that 'favorite' workspaces show up first in the list only for their owner.
|
||||
CASE WHEN owner_id = @requester_id AND favorite THEN 0 ELSE 1 END ASC,
|
||||
(latest_build_completed_at IS NOT NULL AND
|
||||
latest_build_canceled_at IS NULL AND
|
||||
latest_build_error IS NULL AND
|
||||
latest_build_transition = 'start'::workspace_transition) DESC,
|
||||
LOWER(owner_username) ASC,
|
||||
LOWER(name) ASC
|
||||
CASE WHEN fw.owner_id = @requester_id AND fw.favorite THEN 0 ELSE 1 END ASC,
|
||||
(fw.latest_build_completed_at IS NOT NULL AND
|
||||
fw.latest_build_canceled_at IS NULL AND
|
||||
fw.latest_build_error IS NULL AND
|
||||
fw.latest_build_transition = 'start'::workspace_transition) DESC,
|
||||
LOWER(fw.owner_username) ASC,
|
||||
LOWER(fw.name) ASC
|
||||
LIMIT
|
||||
CASE
|
||||
WHEN @limit_ :: integer > 0 THEN
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
-- name: FindOrCreateSessionForDisconnect :one
|
||||
-- Find existing session within time window, or create new one.
|
||||
-- Uses advisory lock to prevent duplicate sessions from concurrent disconnects.
|
||||
-- Groups by client_hostname (with IP fallback) to match the live session
|
||||
-- grouping in mergeWorkspaceConnectionsIntoSessions.
|
||||
WITH lock AS (
|
||||
SELECT pg_advisory_xact_lock(
|
||||
hashtext(@workspace_id::text || COALESCE(@client_hostname, host(@ip::inet), 'unknown'))
|
||||
)
|
||||
),
|
||||
existing AS (
|
||||
SELECT id FROM workspace_sessions
|
||||
WHERE workspace_id = @workspace_id::uuid
|
||||
AND (
|
||||
(@client_hostname IS NOT NULL AND client_hostname = @client_hostname)
|
||||
OR
|
||||
(@client_hostname IS NULL AND client_hostname IS NULL AND ip = @ip::inet)
|
||||
)
|
||||
AND @connect_time BETWEEN started_at - INTERVAL '30 minutes' AND ended_at + INTERVAL '30 minutes'
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
new_session AS (
|
||||
INSERT INTO workspace_sessions (workspace_id, agent_id, ip, client_hostname, short_description, started_at, ended_at)
|
||||
SELECT @workspace_id::uuid, @agent_id, @ip::inet, @client_hostname, @short_description, @connect_time, @disconnect_time
|
||||
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
||||
RETURNING id
|
||||
),
|
||||
updated_session AS (
|
||||
UPDATE workspace_sessions
|
||||
SET started_at = LEAST(started_at, @connect_time),
|
||||
ended_at = GREATEST(ended_at, @disconnect_time)
|
||||
WHERE id = (SELECT id FROM existing)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT COALESCE(
|
||||
(SELECT id FROM updated_session),
|
||||
(SELECT id FROM new_session)
|
||||
) AS id;
|
||||
|
||||
-- name: GetWorkspaceSessionsOffset :many
|
||||
SELECT
|
||||
ws.*,
|
||||
(SELECT COUNT(*) FROM connection_logs cl WHERE cl.session_id = ws.id) AS connection_count
|
||||
FROM workspace_sessions ws
|
||||
WHERE ws.workspace_id = @workspace_id
|
||||
AND CASE WHEN @started_after::timestamptz != '0001-01-01 00:00:00Z'::timestamptz
|
||||
THEN ws.started_at >= @started_after ELSE true END
|
||||
AND CASE WHEN @started_before::timestamptz != '0001-01-01 00:00:00Z'::timestamptz
|
||||
THEN ws.started_at <= @started_before ELSE true END
|
||||
ORDER BY ws.started_at DESC
|
||||
LIMIT @limit_count
|
||||
OFFSET @offset_count;
|
||||
|
||||
-- name: CountWorkspaceSessions :one
|
||||
SELECT COUNT(*) FROM workspace_sessions ws
|
||||
WHERE ws.workspace_id = @workspace_id
|
||||
AND CASE WHEN @started_after::timestamptz != '0001-01-01 00:00:00Z'::timestamptz
|
||||
THEN ws.started_at >= @started_after ELSE true END
|
||||
AND CASE WHEN @started_before::timestamptz != '0001-01-01 00:00:00Z'::timestamptz
|
||||
THEN ws.started_at <= @started_before ELSE true END;
|
||||
|
||||
-- name: GetConnectionLogsBySessionIDs :many
|
||||
SELECT * FROM connection_logs
|
||||
WHERE session_id = ANY(@session_ids::uuid[])
|
||||
ORDER BY session_id, connect_time DESC;
|
||||
|
||||
-- name: GetConnectionLogByConnectionID :one
|
||||
SELECT * FROM connection_logs
|
||||
WHERE connection_id = @connection_id
|
||||
AND workspace_id = @workspace_id
|
||||
AND agent_name = @agent_name
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetGlobalWorkspaceSessionsOffset :many
|
||||
SELECT
|
||||
ws.*,
|
||||
w.name AS workspace_name,
|
||||
workspace_owner.username AS workspace_owner_username,
|
||||
(SELECT COUNT(*) FROM connection_logs cl WHERE cl.session_id = ws.id) AS connection_count
|
||||
FROM workspace_sessions ws
|
||||
JOIN workspaces w ON w.id = ws.workspace_id
|
||||
JOIN users workspace_owner ON workspace_owner.id = w.owner_id
|
||||
WHERE
|
||||
CASE WHEN @workspace_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid
|
||||
THEN ws.workspace_id = @workspace_id ELSE true END
|
||||
AND CASE WHEN @workspace_owner::text != ''
|
||||
THEN workspace_owner.username = @workspace_owner ELSE true END
|
||||
AND CASE WHEN @started_after::timestamptz != '0001-01-01 00:00:00Z'::timestamptz
|
||||
THEN ws.started_at >= @started_after ELSE true END
|
||||
AND CASE WHEN @started_before::timestamptz != '0001-01-01 00:00:00Z'::timestamptz
|
||||
THEN ws.started_at <= @started_before ELSE true END
|
||||
ORDER BY ws.started_at DESC
|
||||
LIMIT @limit_count
|
||||
OFFSET @offset_count;
|
||||
|
||||
-- name: CountGlobalWorkspaceSessions :one
|
||||
SELECT COUNT(*) FROM workspace_sessions ws
|
||||
JOIN workspaces w ON w.id = ws.workspace_id
|
||||
JOIN users workspace_owner ON workspace_owner.id = w.owner_id
|
||||
WHERE
|
||||
CASE WHEN @workspace_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid
|
||||
THEN ws.workspace_id = @workspace_id ELSE true END
|
||||
AND CASE WHEN @workspace_owner::text != ''
|
||||
THEN workspace_owner.username = @workspace_owner ELSE true END
|
||||
AND CASE WHEN @started_after::timestamptz != '0001-01-01 00:00:00Z'::timestamptz
|
||||
THEN ws.started_at >= @started_after ELSE true END
|
||||
AND CASE WHEN @started_before::timestamptz != '0001-01-01 00:00:00Z'::timestamptz
|
||||
THEN ws.started_at <= @started_before ELSE true END;
|
||||
|
||||
@@ -6,6 +6,7 @@ type UniqueConstraint string
|
||||
|
||||
// UniqueConstraint enums.
|
||||
const (
|
||||
UniqueAgentPeeringIDsPkey UniqueConstraint = "agent_peering_ids_pkey" // ALTER TABLE ONLY agent_peering_ids ADD CONSTRAINT agent_peering_ids_pkey PRIMARY KEY (agent_id, peering_id);
|
||||
UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id);
|
||||
UniqueAibridgeInterceptionsPkey UniqueConstraint = "aibridge_interceptions_pkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_pkey PRIMARY KEY (id);
|
||||
UniqueAibridgeTokenUsagesPkey UniqueConstraint = "aibridge_token_usages_pkey" // ALTER TABLE ONLY aibridge_token_usages ADD CONSTRAINT aibridge_token_usages_pkey PRIMARY KEY (id);
|
||||
@@ -108,6 +109,7 @@ const (
|
||||
UniqueWorkspaceResourceMetadataName UniqueConstraint = "workspace_resource_metadata_name" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key);
|
||||
UniqueWorkspaceResourceMetadataPkey UniqueConstraint = "workspace_resource_metadata_pkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_pkey PRIMARY KEY (id);
|
||||
UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id);
|
||||
UniqueWorkspaceSessionsPkey UniqueConstraint = "workspace_sessions_pkey" // ALTER TABLE ONLY workspace_sessions ADD CONSTRAINT workspace_sessions_pkey PRIMARY KEY (id);
|
||||
UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id);
|
||||
UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type);
|
||||
UniqueIndexConnectionLogsConnectionIDWorkspaceIDAgentName UniqueConstraint = "idx_connection_logs_connection_id_workspace_id_agent_name" // CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||||
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestHandleIdentifiedTelemetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PublishesWorkspaceUpdate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
api, dbM, ps := newIdentifiedTelemetryTestAPI(t)
|
||||
ownerID := uuid.New()
|
||||
workspaceID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
}, nil)
|
||||
|
||||
events, errs := subscribeWorkspaceEvents(t, ps, ownerID)
|
||||
|
||||
api.handleIdentifiedTelemetry(agentID, peerID, []*tailnetproto.TelemetryEvent{{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
}})
|
||||
|
||||
select {
|
||||
case err := <-errs:
|
||||
require.NoError(t, err)
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case event := <-events:
|
||||
require.Equal(t, wspubsub.WorkspaceEventKindConnectionLogUpdate, event.Kind)
|
||||
require.Equal(t, workspaceID, event.WorkspaceID)
|
||||
require.NotNil(t, event.AgentID)
|
||||
require.Equal(t, agentID, *event.AgentID)
|
||||
case err := <-errs:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(testutil.IntervalMedium):
|
||||
t.Fatal("timed out waiting for workspace event")
|
||||
}
|
||||
|
||||
require.NotNil(t, api.PeerNetworkTelemetryStore.Get(agentID, peerID))
|
||||
})
|
||||
|
||||
t.Run("EmptyBatchNoPublish", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
api, _, ps := newIdentifiedTelemetryTestAPI(t)
|
||||
events, errs := subscribeWorkspaceEvents(t, ps, uuid.Nil)
|
||||
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
api.handleIdentifiedTelemetry(agentID, peerID, []*tailnetproto.TelemetryEvent{})
|
||||
|
||||
select {
|
||||
case event := <-events:
|
||||
t.Fatalf("unexpected workspace event: %+v", event)
|
||||
case err := <-errs:
|
||||
t.Fatalf("unexpected pubsub error: %v", err)
|
||||
case <-time.After(testutil.IntervalFast):
|
||||
}
|
||||
|
||||
require.Nil(t, api.PeerNetworkTelemetryStore.Get(agentID, peerID))
|
||||
})
|
||||
|
||||
t.Run("LookupFailureNoPublish", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
api, dbM, ps := newIdentifiedTelemetryTestAPI(t)
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(database.Workspace{}, xerrors.New("lookup failed"))
|
||||
|
||||
events, errs := subscribeWorkspaceEvents(t, ps, uuid.Nil)
|
||||
|
||||
api.handleIdentifiedTelemetry(agentID, peerID, []*tailnetproto.TelemetryEvent{{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
}})
|
||||
|
||||
select {
|
||||
case event := <-events:
|
||||
t.Fatalf("unexpected workspace event: %+v", event)
|
||||
case err := <-errs:
|
||||
t.Fatalf("unexpected pubsub error: %v", err)
|
||||
case <-time.After(testutil.IntervalFast):
|
||||
}
|
||||
|
||||
require.NotNil(t, api.PeerNetworkTelemetryStore.Get(agentID, peerID))
|
||||
})
|
||||
}
|
||||
|
||||
func newIdentifiedTelemetryTestAPI(t *testing.T) (*API, *dbmock.MockStore, pubsub.Pubsub) {
|
||||
t.Helper()
|
||||
|
||||
dbM := dbmock.NewMockStore(gomock.NewController(t))
|
||||
ps := pubsub.NewInMemory()
|
||||
|
||||
api := &API{
|
||||
Options: &Options{
|
||||
Database: dbM,
|
||||
Pubsub: ps,
|
||||
Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
|
||||
},
|
||||
PeerNetworkTelemetryStore: NewPeerNetworkTelemetryStore(),
|
||||
}
|
||||
|
||||
return api, dbM, ps
|
||||
}
|
||||
|
||||
func subscribeWorkspaceEvents(t *testing.T, ps pubsub.Pubsub, ownerID uuid.UUID) (<-chan wspubsub.WorkspaceEvent, <-chan error) {
|
||||
t.Helper()
|
||||
|
||||
events := make(chan wspubsub.WorkspaceEvent, 1)
|
||||
errs := make(chan error, 1)
|
||||
cancel, err := ps.SubscribeWithErr(wspubsub.WorkspaceEventChannel(ownerID), wspubsub.HandleWorkspaceEvent(
|
||||
func(_ context.Context, event wspubsub.WorkspaceEvent, err error) {
|
||||
if err != nil {
|
||||
select {
|
||||
case errs <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case events <- event:
|
||||
default:
|
||||
}
|
||||
},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
return events, errs
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
|
||||
)
|
||||
|
||||
const peerNetworkTelemetryMaxAge = 2 * time.Minute
|
||||
|
||||
type PeerNetworkTelemetry struct {
|
||||
P2P *bool
|
||||
DERPLatency *time.Duration
|
||||
P2PLatency *time.Duration
|
||||
HomeDERP int
|
||||
LastUpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PeerNetworkTelemetryStore struct {
|
||||
mu sync.RWMutex
|
||||
byAgent map[uuid.UUID]map[uuid.UUID]*PeerNetworkTelemetry
|
||||
}
|
||||
|
||||
func NewPeerNetworkTelemetryStore() *PeerNetworkTelemetryStore {
|
||||
return &PeerNetworkTelemetryStore{
|
||||
byAgent: make(map[uuid.UUID]map[uuid.UUID]*PeerNetworkTelemetry),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PeerNetworkTelemetryStore) Update(agentID, peerID uuid.UUID, event *tailnetproto.TelemetryEvent) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
if event.Status == tailnetproto.TelemetryEvent_DISCONNECTED {
|
||||
s.Delete(agentID, peerID)
|
||||
return
|
||||
}
|
||||
if event.Status != tailnetproto.TelemetryEvent_CONNECTED {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
byPeer := s.byAgent[agentID]
|
||||
if byPeer == nil {
|
||||
byPeer = make(map[uuid.UUID]*PeerNetworkTelemetry)
|
||||
s.byAgent[agentID] = byPeer
|
||||
}
|
||||
|
||||
existing := byPeer[peerID]
|
||||
entry := &PeerNetworkTelemetry{
|
||||
LastUpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// HomeDERP: prefer explicit non-zero value from the event,
|
||||
// otherwise preserve the prior known value.
|
||||
if event.HomeDerp != 0 {
|
||||
entry.HomeDERP = int(event.HomeDerp)
|
||||
} else if existing != nil {
|
||||
entry.HomeDERP = existing.HomeDERP
|
||||
}
|
||||
|
||||
// Determine whether this event carries any mode/latency signal.
|
||||
hasNetworkInfo := event.P2PEndpoint != nil || event.DerpLatency != nil || event.P2PLatency != nil
|
||||
|
||||
if hasNetworkInfo {
|
||||
// Apply explicit values from the event.
|
||||
if event.P2PEndpoint != nil {
|
||||
p2p := true
|
||||
entry.P2P = &p2p
|
||||
}
|
||||
if event.DerpLatency != nil {
|
||||
d := event.DerpLatency.AsDuration()
|
||||
entry.DERPLatency = &d
|
||||
p2p := false
|
||||
entry.P2P = &p2p
|
||||
}
|
||||
if event.P2PLatency != nil {
|
||||
d := event.P2PLatency.AsDuration()
|
||||
entry.P2PLatency = &d
|
||||
p2p := true
|
||||
entry.P2P = &p2p
|
||||
}
|
||||
} else if existing != nil {
|
||||
// Event has no mode/latency info — preserve prior values
|
||||
// so a bare CONNECTED event doesn't wipe known state.
|
||||
entry.P2P = existing.P2P
|
||||
entry.DERPLatency = existing.DERPLatency
|
||||
entry.P2PLatency = existing.P2PLatency
|
||||
}
|
||||
|
||||
byPeer[peerID] = entry
|
||||
}
|
||||
|
||||
func (s *PeerNetworkTelemetryStore) Get(agentID uuid.UUID, peerID ...uuid.UUID) *PeerNetworkTelemetry {
|
||||
if len(peerID) > 0 {
|
||||
return s.getByPeer(agentID, peerID[0])
|
||||
}
|
||||
|
||||
// Legacy callers only provide agentID. Return the freshest peer entry.
|
||||
entries := s.GetAll(agentID)
|
||||
var latest *PeerNetworkTelemetry
|
||||
for _, entry := range entries {
|
||||
if latest == nil || entry.LastUpdatedAt.After(latest.LastUpdatedAt) {
|
||||
latest = entry
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func (s *PeerNetworkTelemetryStore) getByPeer(agentID, peerID uuid.UUID) *PeerNetworkTelemetry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
byPeer := s.byAgent[agentID]
|
||||
if byPeer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
entry := byPeer[peerID]
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Since(entry.LastUpdatedAt) > peerNetworkTelemetryMaxAge {
|
||||
delete(byPeer, peerID)
|
||||
if len(byPeer) == 0 {
|
||||
delete(s.byAgent, agentID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func (s *PeerNetworkTelemetryStore) GetAll(agentID uuid.UUID) map[uuid.UUID]*PeerNetworkTelemetry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
byPeer := s.byAgent[agentID]
|
||||
if len(byPeer) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries := make(map[uuid.UUID]*PeerNetworkTelemetry, len(byPeer))
|
||||
now := time.Now()
|
||||
for peerID, entry := range byPeer {
|
||||
if now.Sub(entry.LastUpdatedAt) > peerNetworkTelemetryMaxAge {
|
||||
delete(byPeer, peerID)
|
||||
continue
|
||||
}
|
||||
entries[peerID] = entry
|
||||
}
|
||||
|
||||
if len(byPeer) == 0 {
|
||||
delete(s.byAgent, agentID)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (s *PeerNetworkTelemetryStore) Delete(agentID, peerID uuid.UUID) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
byPeer := s.byAgent[agentID]
|
||||
if byPeer == nil {
|
||||
return
|
||||
}
|
||||
delete(byPeer, peerID)
|
||||
if len(byPeer) == 0 {
|
||||
delete(s.byAgent, agentID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
|
||||
)
|
||||
|
||||
func TestPeerNetworkTelemetryStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("UpdateAndGet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
P2PLatency: durationpb.New(50 * time.Millisecond),
|
||||
HomeDerp: 1,
|
||||
})
|
||||
|
||||
got := store.Get(agentID, peerID)
|
||||
require.NotNil(t, got)
|
||||
require.NotNil(t, got.P2P)
|
||||
require.True(t, *got.P2P)
|
||||
require.NotNil(t, got.P2PLatency)
|
||||
require.Equal(t, 50*time.Millisecond, *got.P2PLatency)
|
||||
require.Equal(t, 1, got.HomeDERP)
|
||||
})
|
||||
|
||||
t.Run("GetMissing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
require.Nil(t, store.Get(uuid.New(), uuid.New()))
|
||||
})
|
||||
|
||||
t.Run("LatestWins", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
P2PLatency: durationpb.New(10 * time.Millisecond),
|
||||
HomeDerp: 1,
|
||||
})
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
DerpLatency: durationpb.New(75 * time.Millisecond),
|
||||
HomeDerp: 2,
|
||||
})
|
||||
|
||||
got := store.Get(agentID, peerID)
|
||||
require.NotNil(t, got)
|
||||
require.NotNil(t, got.P2P)
|
||||
require.False(t, *got.P2P)
|
||||
require.NotNil(t, got.DERPLatency)
|
||||
require.Equal(t, 75*time.Millisecond, *got.DERPLatency)
|
||||
require.Nil(t, got.P2PLatency)
|
||||
require.Equal(t, 2, got.HomeDERP)
|
||||
})
|
||||
|
||||
t.Run("ConnectedWithoutLatencyPreservesExistingModeAndLatency", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
P2PLatency: durationpb.New(50 * time.Millisecond),
|
||||
HomeDerp: 1,
|
||||
})
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
})
|
||||
|
||||
got := store.Get(agentID, peerID)
|
||||
require.NotNil(t, got)
|
||||
require.NotNil(t, got.P2P)
|
||||
require.True(t, *got.P2P)
|
||||
require.NotNil(t, got.P2PLatency)
|
||||
require.Equal(t, 50*time.Millisecond, *got.P2PLatency)
|
||||
require.Equal(t, 1, got.HomeDERP)
|
||||
})
|
||||
|
||||
t.Run("ConnectedWithHomeDerpZeroPreservesPreviousHomeDerp", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
HomeDerp: 3,
|
||||
})
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
DerpLatency: durationpb.New(20 * time.Millisecond),
|
||||
})
|
||||
|
||||
got := store.Get(agentID, peerID)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, 3, got.HomeDERP)
|
||||
require.NotNil(t, got.P2P)
|
||||
require.False(t, *got.P2P)
|
||||
require.NotNil(t, got.DERPLatency)
|
||||
require.Equal(t, 20*time.Millisecond, *got.DERPLatency)
|
||||
})
|
||||
|
||||
t.Run("ConnectedWithExplicitLatencyOverridesPreviousValues", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
P2PLatency: durationpb.New(50 * time.Millisecond),
|
||||
})
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
DerpLatency: durationpb.New(30 * time.Millisecond),
|
||||
})
|
||||
|
||||
got := store.Get(agentID, peerID)
|
||||
require.NotNil(t, got)
|
||||
require.NotNil(t, got.P2P)
|
||||
require.False(t, *got.P2P)
|
||||
require.NotNil(t, got.DERPLatency)
|
||||
require.Equal(t, 30*time.Millisecond, *got.DERPLatency)
|
||||
require.Nil(t, got.P2PLatency)
|
||||
})
|
||||
|
||||
t.Run("ConnectedWithoutLatencyLeavesModeUnknown", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
HomeDerp: 1,
|
||||
})
|
||||
|
||||
got := store.Get(agentID, peerID)
|
||||
require.NotNil(t, got)
|
||||
require.Nil(t, got.P2P)
|
||||
require.Nil(t, got.DERPLatency)
|
||||
require.Nil(t, got.P2PLatency)
|
||||
})
|
||||
|
||||
t.Run("TwoPeersIndependentState", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerA := uuid.New()
|
||||
peerB := uuid.New()
|
||||
|
||||
store.Update(agentID, peerA, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
P2PLatency: durationpb.New(10 * time.Millisecond),
|
||||
HomeDerp: 1,
|
||||
})
|
||||
store.Update(agentID, peerB, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
DerpLatency: durationpb.New(80 * time.Millisecond),
|
||||
HomeDerp: 2,
|
||||
})
|
||||
|
||||
gotA := store.Get(agentID, peerA)
|
||||
require.NotNil(t, gotA)
|
||||
require.NotNil(t, gotA.P2P)
|
||||
require.True(t, *gotA.P2P)
|
||||
require.NotNil(t, gotA.P2PLatency)
|
||||
require.Equal(t, 10*time.Millisecond, *gotA.P2PLatency)
|
||||
require.Nil(t, gotA.DERPLatency)
|
||||
require.Equal(t, 1, gotA.HomeDERP)
|
||||
|
||||
gotB := store.Get(agentID, peerB)
|
||||
require.NotNil(t, gotB)
|
||||
require.NotNil(t, gotB.P2P)
|
||||
require.False(t, *gotB.P2P)
|
||||
require.NotNil(t, gotB.DERPLatency)
|
||||
require.Equal(t, 80*time.Millisecond, *gotB.DERPLatency)
|
||||
require.Nil(t, gotB.P2PLatency)
|
||||
require.Equal(t, 2, gotB.HomeDERP)
|
||||
|
||||
all := store.GetAll(agentID)
|
||||
require.Len(t, all, 2)
|
||||
require.Same(t, gotA, all[peerA])
|
||||
require.Same(t, gotB, all[peerB])
|
||||
})
|
||||
|
||||
t.Run("PeerDisconnectDoesNotWipeOther", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerA := uuid.New()
|
||||
peerB := uuid.New()
|
||||
|
||||
store.Update(agentID, peerA, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
P2PLatency: durationpb.New(15 * time.Millisecond),
|
||||
HomeDerp: 5,
|
||||
})
|
||||
store.Update(agentID, peerB, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
DerpLatency: durationpb.New(70 * time.Millisecond),
|
||||
HomeDerp: 6,
|
||||
})
|
||||
|
||||
store.Update(agentID, peerA, &tailnetproto.TelemetryEvent{Status: tailnetproto.TelemetryEvent_DISCONNECTED})
|
||||
|
||||
require.Nil(t, store.Get(agentID, peerA))
|
||||
gotB := store.Get(agentID, peerB)
|
||||
require.NotNil(t, gotB)
|
||||
require.NotNil(t, gotB.P2P)
|
||||
require.False(t, *gotB.P2P)
|
||||
require.Equal(t, 6, gotB.HomeDERP)
|
||||
require.NotNil(t, gotB.DERPLatency)
|
||||
require.Equal(t, 70*time.Millisecond, *gotB.DERPLatency)
|
||||
|
||||
all := store.GetAll(agentID)
|
||||
require.Len(t, all, 1)
|
||||
require.Contains(t, all, peerB)
|
||||
})
|
||||
|
||||
t.Run("DisconnectedDeletes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
P2PLatency: durationpb.New(15 * time.Millisecond),
|
||||
})
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{Status: tailnetproto.TelemetryEvent_DISCONNECTED})
|
||||
|
||||
require.Nil(t, store.Get(agentID, peerID))
|
||||
})
|
||||
|
||||
t.Run("StaleEntryEvicted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
P2PLatency: durationpb.New(20 * time.Millisecond),
|
||||
})
|
||||
|
||||
entry := store.Get(agentID, peerID)
|
||||
require.NotNil(t, entry)
|
||||
entry.LastUpdatedAt = time.Now().Add(-3 * time.Minute)
|
||||
|
||||
require.Nil(t, store.Get(agentID, peerID))
|
||||
require.Nil(t, store.Get(agentID, peerID))
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentID := uuid.New()
|
||||
peerID := uuid.New()
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
P2PLatency: durationpb.New(15 * time.Millisecond),
|
||||
})
|
||||
|
||||
store.Delete(agentID, peerID)
|
||||
require.Nil(t, store.Get(agentID, peerID))
|
||||
})
|
||||
|
||||
t.Run("ConcurrentAccess", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := coderd.NewPeerNetworkTelemetryStore()
|
||||
agentIDs := make([]uuid.UUID, 8)
|
||||
for i := range agentIDs {
|
||||
agentIDs[i] = uuid.New()
|
||||
}
|
||||
peerIDs := make([]uuid.UUID, 16)
|
||||
for i := range peerIDs {
|
||||
peerIDs[i] = uuid.New()
|
||||
}
|
||||
|
||||
const (
|
||||
goroutines = 8
|
||||
iterations = 100
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for g := 0; g < goroutines; g++ {
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
agentID := agentIDs[(worker+i)%len(agentIDs)]
|
||||
peerID := peerIDs[(worker*iterations+i)%len(peerIDs)]
|
||||
store.Update(agentID, peerID, &tailnetproto.TelemetryEvent{
|
||||
Status: tailnetproto.TelemetryEvent_CONNECTED,
|
||||
P2PLatency: durationpb.New(time.Duration(i+1) * time.Millisecond),
|
||||
HomeDerp: int32(worker + 1), //nolint:gosec // test data, worker is small
|
||||
})
|
||||
_ = store.Get(agentID, peerID)
|
||||
_ = store.GetAll(agentID)
|
||||
if i%10 == 0 {
|
||||
store.Delete(agentID, peerID)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package provisionerdserver_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/provisionerdserver"
|
||||
"github.com/coder/coder/v2/provisionerd/proto"
|
||||
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func TestCompleteJob_ClosesOpenConnectionLogsOnStopOrDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
transition database.WorkspaceTransition
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "Stop",
|
||||
transition: database.WorkspaceTransitionStop,
|
||||
reason: "workspace stopped",
|
||||
},
|
||||
{
|
||||
name: "Delete",
|
||||
transition: database.WorkspaceTransitionDelete,
|
||||
reason: "workspace deleted",
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv, db, ps, pd := setup(t, false, nil)
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
template := dbgen.Template(t, db, database.Template{
|
||||
Name: "template",
|
||||
CreatedBy: user.ID,
|
||||
Provisioner: database.ProvisionerTypeEcho,
|
||||
OrganizationID: pd.OrganizationID,
|
||||
})
|
||||
file := dbgen.File(t, db, database.File{CreatedBy: user.ID})
|
||||
workspaceTable := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
TemplateID: template.ID,
|
||||
OwnerID: user.ID,
|
||||
OrganizationID: pd.OrganizationID,
|
||||
})
|
||||
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
||||
OrganizationID: pd.OrganizationID,
|
||||
CreatedBy: user.ID,
|
||||
TemplateID: uuid.NullUUID{
|
||||
UUID: template.ID,
|
||||
Valid: true,
|
||||
},
|
||||
JobID: uuid.New(),
|
||||
})
|
||||
|
||||
wsBuildID := uuid.New()
|
||||
job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
|
||||
ID: uuid.New(),
|
||||
FileID: file.ID,
|
||||
InitiatorID: user.ID,
|
||||
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
||||
Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{
|
||||
WorkspaceBuildID: wsBuildID,
|
||||
})),
|
||||
OrganizationID: pd.OrganizationID,
|
||||
})
|
||||
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
||||
ID: wsBuildID,
|
||||
JobID: job.ID,
|
||||
WorkspaceID: workspaceTable.ID,
|
||||
TemplateVersionID: version.ID,
|
||||
BuildNumber: 2,
|
||||
Transition: tc.transition,
|
||||
Reason: database.BuildReasonInitiator,
|
||||
})
|
||||
|
||||
_, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
|
||||
OrganizationID: pd.OrganizationID,
|
||||
WorkerID: uuid.NullUUID{
|
||||
UUID: pd.ID,
|
||||
Valid: true,
|
||||
},
|
||||
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
ProvisionerTags: must(json.Marshal(job.Tags)),
|
||||
StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert an open SSH connection log for the workspace.
|
||||
ip := pqtype.Inet{
|
||||
Valid: true,
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(127, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
}
|
||||
|
||||
openLog, err := db.UpsertConnectionLog(ctx, database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: dbtime.Now(),
|
||||
OrganizationID: workspaceTable.OrganizationID,
|
||||
WorkspaceOwnerID: workspaceTable.OwnerID,
|
||||
WorkspaceID: workspaceTable.ID,
|
||||
WorkspaceName: workspaceTable.Name,
|
||||
AgentName: "agent",
|
||||
Type: database.ConnectionTypeSsh,
|
||||
Ip: ip,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, openLog.DisconnectTime.Valid)
|
||||
|
||||
_, err = srv.CompleteJob(ctx, &proto.CompletedJob{
|
||||
JobId: job.ID.String(),
|
||||
Type: &proto.CompletedJob_WorkspaceBuild_{
|
||||
WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{
|
||||
State: []byte{},
|
||||
Resources: []*sdkproto.Resource{},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{WorkspaceID: workspaceTable.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
|
||||
updated := rows[0].ConnectionLog
|
||||
require.Equal(t, openLog.ID, updated.ID)
|
||||
require.True(t, updated.DisconnectTime.Valid)
|
||||
require.True(t, updated.DisconnectReason.Valid)
|
||||
require.Equal(t, tc.reason, updated.DisconnectReason.String)
|
||||
require.False(t, updated.DisconnectTime.Time.Before(updated.ConnectTime), "disconnect_time should never be before connect_time")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1919,6 +1919,7 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro
|
||||
|
||||
var workspace database.Workspace
|
||||
var getWorkspaceError error
|
||||
var completedAt time.Time
|
||||
|
||||
// Execute all database modifications in a transaction
|
||||
err = s.Database.InTx(func(db database.Store) error {
|
||||
@@ -1926,6 +1927,8 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro
|
||||
// able to customize the current time from within tests.
|
||||
now := s.timeNow()
|
||||
|
||||
completedAt = now
|
||||
|
||||
workspace, getWorkspaceError = db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
|
||||
if getWorkspaceError != nil {
|
||||
s.Logger.Error(ctx,
|
||||
@@ -2339,6 +2342,51 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro
|
||||
// Post-transaction operations (operations that do not require transactions or
|
||||
// are external to the database, like audit logging, notifications, etc.)
|
||||
|
||||
if workspaceBuild.Transition == database.WorkspaceTransitionStop ||
|
||||
workspaceBuild.Transition == database.WorkspaceTransitionDelete {
|
||||
reason := "workspace stopped"
|
||||
if workspaceBuild.Transition == database.WorkspaceTransitionDelete {
|
||||
reason = "workspace deleted"
|
||||
}
|
||||
|
||||
agentConnectionTypes := []database.ConnectionType{
|
||||
database.ConnectionTypeSsh,
|
||||
database.ConnectionTypeVscode,
|
||||
database.ConnectionTypeJetbrains,
|
||||
database.ConnectionTypeReconnectingPty,
|
||||
database.ConnectionTypeWorkspaceApp,
|
||||
database.ConnectionTypePortForwarding,
|
||||
database.ConnectionTypeSystem,
|
||||
}
|
||||
|
||||
//nolint:gocritic // Best-effort cleanup should not depend on RPC context.
|
||||
sysCtx, cancel := context.WithTimeout(
|
||||
dbauthz.AsConnectionLogger(s.lifecycleCtx),
|
||||
5*time.Second,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
rowsClosed, closeErr := s.Database.CloseConnectionLogsAndCreateSessions(sysCtx, database.CloseConnectionLogsAndCreateSessionsParams{
|
||||
WorkspaceID: workspaceBuild.WorkspaceID,
|
||||
ClosedAt: sql.NullTime{Time: completedAt, Valid: true},
|
||||
Reason: sql.NullString{String: reason, Valid: true},
|
||||
Types: agentConnectionTypes,
|
||||
})
|
||||
if closeErr != nil {
|
||||
s.Logger.Warn(ctx, "close open connection logs failed",
|
||||
slog.F("workspace_id", workspaceBuild.WorkspaceID),
|
||||
slog.F("workspace_build_id", workspaceBuild.ID),
|
||||
slog.F("transition", workspaceBuild.Transition),
|
||||
slog.Error(closeErr),
|
||||
)
|
||||
} else if rowsClosed > 0 {
|
||||
s.Logger.Info(ctx, "closed open connection logs",
|
||||
slog.F("workspace_id", workspaceBuild.WorkspaceID),
|
||||
slog.F("rows", rowsClosed),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// audit the outcome of the workspace build
|
||||
if getWorkspaceError == nil {
|
||||
// If the workspace has been deleted, notify the owner about it.
|
||||
|
||||
@@ -74,6 +74,7 @@ const (
|
||||
SubjectTypeSystemReadProvisionerDaemons SubjectType = "system_read_provisioner_daemons"
|
||||
SubjectTypeSystemRestricted SubjectType = "system_restricted"
|
||||
SubjectTypeSystemOAuth SubjectType = "system_oauth"
|
||||
SubjectTypeTailnetCoordinator SubjectType = "tailnet_coordinator"
|
||||
SubjectTypeNotifier SubjectType = "notifier"
|
||||
SubjectTypeSubAgentAPI SubjectType = "sub_agent_api"
|
||||
SubjectTypeFileReader SubjectType = "file_reader"
|
||||
|
||||
@@ -142,6 +142,46 @@ func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey
|
||||
return filter, countFilter, parser.Errors
|
||||
}
|
||||
|
||||
// WorkspaceSessions parses a search query string into database filter
|
||||
// parameters for the global workspace sessions endpoint.
|
||||
func WorkspaceSessions(_ context.Context, _ database.Store, query string, _ database.APIKey) (database.GetGlobalWorkspaceSessionsOffsetParams, database.CountGlobalWorkspaceSessionsParams, []codersdk.ValidationError) {
|
||||
// Always lowercase for all searches.
|
||||
query = strings.ToLower(query)
|
||||
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
||||
values.Add("search", term)
|
||||
return nil
|
||||
})
|
||||
if len(errors) > 0 {
|
||||
// nolint:exhaustruct // We don't need to initialize these structs because we return an error.
|
||||
return database.GetGlobalWorkspaceSessionsOffsetParams{}, database.CountGlobalWorkspaceSessionsParams{}, errors
|
||||
}
|
||||
|
||||
parser := httpapi.NewQueryParamParser()
|
||||
filter := database.GetGlobalWorkspaceSessionsOffsetParams{
|
||||
WorkspaceOwner: parser.String(values, "", "workspace_owner"),
|
||||
WorkspaceID: parser.UUID(values, uuid.Nil, "workspace_id"),
|
||||
StartedAfter: parser.Time3339Nano(values, time.Time{}, "started_after"),
|
||||
StartedBefore: parser.Time3339Nano(values, time.Time{}, "started_before"),
|
||||
}
|
||||
|
||||
if filter.WorkspaceOwner == "me" {
|
||||
// The "me" keyword is not supported for workspace_owner in
|
||||
// global sessions since we filter by workspace owner, not
|
||||
// the requesting user. Reset to empty to avoid confusion.
|
||||
filter.WorkspaceOwner = ""
|
||||
}
|
||||
|
||||
// This MUST be kept in sync with the above.
|
||||
countFilter := database.CountGlobalWorkspaceSessionsParams{
|
||||
WorkspaceOwner: filter.WorkspaceOwner,
|
||||
WorkspaceID: filter.WorkspaceID,
|
||||
StartedAfter: filter.StartedAfter,
|
||||
StartedBefore: filter.StartedBefore,
|
||||
}
|
||||
parser.ErrorExcessParams(values)
|
||||
return filter, countFilter, parser.Errors
|
||||
}
|
||||
|
||||
func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) {
|
||||
// Always lowercase for all searches.
|
||||
query = strings.ToLower(query)
|
||||
|
||||
+10
-2
@@ -49,22 +49,30 @@ func init() {
|
||||
|
||||
var _ workspaceapps.AgentProvider = (*ServerTailnet)(nil)
|
||||
|
||||
// NewServerTailnet creates a new tailnet intended for use by coderd.
|
||||
// NewServerTailnet creates a new tailnet intended for use by coderd. The
|
||||
// clientID is used to derive deterministic tailnet IP addresses for the
|
||||
// server, matching how agents derive IPs from their agent ID.
|
||||
func NewServerTailnet(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
derpServer *derp.Server,
|
||||
clientID uuid.UUID,
|
||||
dialer tailnet.ControlProtocolDialer,
|
||||
derpForceWebSockets bool,
|
||||
blockEndpoints bool,
|
||||
traceProvider trace.TracerProvider,
|
||||
shortDescription string,
|
||||
) (*ServerTailnet, error) {
|
||||
logger = logger.Named("servertailnet")
|
||||
conn, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.RandomPrefix()},
|
||||
Addresses: []netip.Prefix{
|
||||
tailnet.TailscaleServicePrefix.PrefixFromUUID(clientID),
|
||||
tailnet.CoderServicePrefix.PrefixFromUUID(clientID),
|
||||
},
|
||||
DERPForceWebSockets: derpForceWebSockets,
|
||||
Logger: logger,
|
||||
BlockEndpoints: blockEndpoints,
|
||||
ShortDescription: shortDescription,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create tailnet conn: %w", err)
|
||||
|
||||
@@ -480,10 +480,12 @@ func setupServerTailnetAgent(t *testing.T, agentNum int, opts ...tailnettest.DER
|
||||
context.Background(),
|
||||
logger,
|
||||
derpServer,
|
||||
uuid.UUID{5},
|
||||
dialer,
|
||||
false,
|
||||
!derpMap.HasSTUN(),
|
||||
trace.NewNoopTracerProvider(),
|
||||
"Coder Server",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -124,6 +124,34 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// nolint:gocritic // Intentionally visible to any authorized workspace reader.
|
||||
connectionLogs, err := getOngoingAgentConnectionsLast24h(
|
||||
dbauthz.AsSystemRestricted(ctx),
|
||||
api.Database,
|
||||
[]uuid.UUID{waws.WorkspaceTable.ID},
|
||||
[]string{waws.WorkspaceAgent.Name},
|
||||
)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace agent connections.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// nolint:gocritic // Reading tailnet peering events requires coordinator
|
||||
// context since they record control-plane state transitions.
|
||||
peeringEvents, err := api.Database.GetAllTailnetPeeringEventsByPeerID(dbauthz.AsTailnetCoordinator(api.ctx), uuid.NullUUID{UUID: waws.WorkspaceAgent.ID, Valid: true})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace agent peering events.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
peerTelemetry := api.PeerNetworkTelemetryStore.GetAll(waws.WorkspaceAgent.ID)
|
||||
if sessions := mergeWorkspaceConnectionsIntoSessions(waws.WorkspaceAgent.ID, peeringEvents, connectionLogs, api.DERPMap(), peerTelemetry); len(sessions) > 0 {
|
||||
apiAgent.Sessions = sessions
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, apiAgent)
|
||||
}
|
||||
|
||||
@@ -1529,8 +1557,12 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
|
||||
go httpapi.HeartbeatClose(ctx, api.Logger, cancel, conn)
|
||||
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
peerName := "client"
|
||||
if key, ok := httpmw.APIKeyOptional(r); ok {
|
||||
peerName = key.UserID.String()
|
||||
}
|
||||
err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, tailnet.StreamID{
|
||||
Name: "client",
|
||||
Name: peerName,
|
||||
ID: peerID,
|
||||
Auth: tailnet.ClientCoordinateeAuth{
|
||||
AgentID: waws.WorkspaceAgent.ID,
|
||||
@@ -2340,7 +2372,7 @@ func (api *API) tailnetRPCConn(rw http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
go httpapi.HeartbeatClose(ctx, api.Logger, cancel, conn)
|
||||
err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, tailnet.StreamID{
|
||||
Name: "client",
|
||||
Name: apiKey.UserID.String(),
|
||||
ID: peerID,
|
||||
Auth: tailnet.ClientUserCoordinateeAuth{
|
||||
Auth: &rbacAuthorizer{
|
||||
|
||||
+31
-14
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
@@ -444,7 +445,12 @@ func (p *DBTokenProvider) connLogInitRequest(w http.ResponseWriter, r *http.Requ
|
||||
userID = aReq.apiKey.UserID
|
||||
}
|
||||
userAgent := r.UserAgent()
|
||||
// Strip the port from RemoteAddr (which is "host:port")
|
||||
// so that database.ParseIP can parse the bare IP address.
|
||||
ip := r.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(ip); err == nil {
|
||||
ip = host
|
||||
}
|
||||
|
||||
// Approximation of the status code.
|
||||
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
|
||||
@@ -479,12 +485,13 @@ func (p *DBTokenProvider) connLogInitRequest(w http.ResponseWriter, r *http.Requ
|
||||
slog.F("status_code", statusCode),
|
||||
)
|
||||
|
||||
var newOrStale bool
|
||||
connectionID := uuid.New()
|
||||
var result database.UpsertWorkspaceAppAuditSessionRow
|
||||
err := p.Database.InTx(func(tx database.Store) (err error) {
|
||||
// nolint:gocritic // System context is needed to write audit sessions.
|
||||
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
newOrStale, err = tx.UpsertWorkspaceAppAuditSession(dangerousSystemCtx, database.UpsertWorkspaceAppAuditSessionParams{
|
||||
result, err = tx.UpsertWorkspaceAppAuditSession(dangerousSystemCtx, database.UpsertWorkspaceAppAuditSessionParams{
|
||||
// Config.
|
||||
StaleIntervalMS: p.WorkspaceAppAuditSessionTimeout.Milliseconds(),
|
||||
|
||||
@@ -499,6 +506,10 @@ func (p *DBTokenProvider) connLogInitRequest(w http.ResponseWriter, r *http.Requ
|
||||
StatusCode: statusCode,
|
||||
StartedAt: aReq.time,
|
||||
UpdatedAt: aReq.time,
|
||||
ConnectionID: uuid.NullUUID{
|
||||
UUID: connectionID,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert workspace app audit session: %w", err)
|
||||
@@ -514,15 +525,8 @@ func (p *DBTokenProvider) connLogInitRequest(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if !newOrStale {
|
||||
// We either didn't insert a new session, or the session
|
||||
// didn't timeout due to inactivity.
|
||||
return
|
||||
}
|
||||
|
||||
connLogger := *p.ConnectionLogger.Load()
|
||||
|
||||
err = connLogger.Upsert(ctx, database.UpsertConnectionLogParams{
|
||||
upsertParams := database.UpsertConnectionLogParams{
|
||||
ID: uuid.New(),
|
||||
Time: aReq.time,
|
||||
OrganizationID: aReq.dbReq.Workspace.OrganizationID,
|
||||
@@ -530,6 +534,7 @@ func (p *DBTokenProvider) connLogInitRequest(w http.ResponseWriter, r *http.Requ
|
||||
WorkspaceID: aReq.dbReq.Workspace.ID,
|
||||
WorkspaceName: aReq.dbReq.Workspace.Name,
|
||||
AgentName: aReq.dbReq.Agent.Name,
|
||||
AgentID: uuid.NullUUID{UUID: aReq.dbReq.Agent.ID, Valid: true},
|
||||
Type: connType,
|
||||
Code: sql.NullInt32{
|
||||
Int32: statusCode,
|
||||
@@ -543,11 +548,23 @@ func (p *DBTokenProvider) connLogInitRequest(w http.ResponseWriter, r *http.Requ
|
||||
},
|
||||
SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
|
||||
// N/A
|
||||
ConnectionID: uuid.NullUUID{},
|
||||
ConnectionID: result.ConnectionID,
|
||||
DisconnectReason: sql.NullString{},
|
||||
})
|
||||
SessionID: uuid.NullUUID{},
|
||||
ClientHostname: sql.NullString{},
|
||||
ShortDescription: sql.NullString{},
|
||||
}
|
||||
|
||||
if !result.NewOrStale {
|
||||
// Session still active. Bump updated_at on the existing
|
||||
// connection log via the ON CONFLICT path.
|
||||
if err := connLogger.Upsert(ctx, upsertParams); err != nil {
|
||||
logger.Error(ctx, "bump connection log updated_at failed", slog.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = connLogger.Upsert(ctx, upsertParams)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "upsert connection log failed", slog.Error(err))
|
||||
return
|
||||
|
||||
@@ -318,7 +318,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.Equal(t, codersdk.SignedAppTokenCookie, cookie.Name)
|
||||
require.Equal(t, req.BasePath, cookie.Path)
|
||||
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
|
||||
var parsedToken workspaceapps.SignedToken
|
||||
@@ -398,7 +398,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.NotNil(t, token)
|
||||
require.Zero(t, w.StatusCode)
|
||||
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, secondUser.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, app, database.ConnectionTypeWorkspaceApp, secondUser.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
}
|
||||
})
|
||||
@@ -438,7 +438,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.NotZero(t, rw.Code)
|
||||
require.NotEqual(t, http.StatusOK, rw.Code)
|
||||
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, uuid.Nil)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, app, database.ConnectionTypeWorkspaceApp, uuid.Nil)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
} else {
|
||||
if !assert.True(t, ok) {
|
||||
@@ -452,7 +452,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
t.Fatalf("expected 200 (or unset) response code, got %d", rw.Code)
|
||||
}
|
||||
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, uuid.Nil)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, app, database.ConnectionTypeWorkspaceApp, uuid.Nil)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
}
|
||||
_ = w.Body.Close()
|
||||
@@ -577,7 +577,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.Equal(t, token.AgentNameOrID, c.agent)
|
||||
require.Equal(t, token.WorkspaceID, workspace.ID)
|
||||
require.Equal(t, token.AgentID, agentID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
} else {
|
||||
require.Nil(t, token)
|
||||
@@ -663,7 +663,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort)
|
||||
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
})
|
||||
|
||||
@@ -736,7 +736,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
|
||||
require.Equal(t, "http://127.0.0.1:9090", token.AppURL)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, "9090", database.ConnectionTypePortForwarding, me.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, "9090", database.ConnectionTypePortForwarding, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
})
|
||||
|
||||
@@ -809,7 +809,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameEndsInS, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, appNameEndsInS, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
})
|
||||
|
||||
@@ -846,7 +846,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.Equal(t, req.AgentNameOrID, token.Request.AgentNameOrID)
|
||||
require.Empty(t, token.AppSlugOrPort)
|
||||
require.Empty(t, token.AppURL)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, "terminal", database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, "terminal", database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
})
|
||||
|
||||
@@ -880,7 +880,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, secondUser.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, secondUser.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
})
|
||||
|
||||
@@ -954,7 +954,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.StatusCode)
|
||||
// Note that we don't capture the owner UUID here because the apiKey
|
||||
// check/authorization exits early.
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, uuid.Nil)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, uuid.Nil)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
|
||||
loc, err := w.Location()
|
||||
@@ -1016,7 +1016,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
w := rw.Result()
|
||||
defer w.Body.Close()
|
||||
require.Equal(t, http.StatusBadGateway, w.StatusCode)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentNameUnhealthy, appNameAgentUnhealthy, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, uuid.Nil, agentNameUnhealthy, appNameAgentUnhealthy, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
|
||||
body, err := io.ReadAll(w.Body)
|
||||
@@ -1075,7 +1075,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
})
|
||||
require.True(t, ok, "ResolveRequest failed, should pass even though app is initializing")
|
||||
require.NotNil(t, token)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
})
|
||||
|
||||
@@ -1133,7 +1133,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
})
|
||||
require.True(t, ok, "ResolveRequest failed, should pass even though app is unhealthy")
|
||||
require.NotNil(t, token)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
})
|
||||
|
||||
@@ -1170,7 +1170,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1)
|
||||
|
||||
// Second request, no audit log because the session is active.
|
||||
@@ -1188,7 +1188,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 1, "single connection log, previous session active")
|
||||
require.Len(t, connLogger.ConnectionLogs(), 2, "one connection log, two upserts (updated_at bump)")
|
||||
|
||||
// Third request, session timed out, new audit log.
|
||||
rw = httptest.NewRecorder()
|
||||
@@ -1206,8 +1206,8 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 2, "two connection logs, session timed out")
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 3, "two connection logs, three upserts (session timed out)")
|
||||
|
||||
// Fourth request, new IP produces new audit log.
|
||||
auditableIP = testutil.RandomIPv6(t)
|
||||
@@ -1225,8 +1225,8 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 3, "three connection logs, new IP")
|
||||
assertConnLogContains(t, rw, r, connLogger, workspace, agentID, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
||||
require.Len(t, connLogger.ConnectionLogs(), 4, "three connection logs, four upserts (new IP)")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1267,7 +1267,7 @@ func signedTokenProviderWithConnLogger(t testing.TB, provider workspaceapps.Sign
|
||||
return &shallowCopy
|
||||
}
|
||||
|
||||
func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http.Request, connLogger *connectionlog.FakeConnectionLogger, workspace codersdk.Workspace, agentName string, slugOrPort string, typ database.ConnectionType, userID uuid.UUID) {
|
||||
func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http.Request, connLogger *connectionlog.FakeConnectionLogger, workspace codersdk.Workspace, agentID uuid.UUID, agentName string, slugOrPort string, typ database.ConnectionType, userID uuid.UUID) {
|
||||
t.Helper()
|
||||
|
||||
resp := rr.Result()
|
||||
@@ -1279,6 +1279,7 @@ func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http.
|
||||
WorkspaceID: workspace.ID,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agentName,
|
||||
AgentID: uuid.NullUUID{UUID: agentID, Valid: agentID != uuid.Nil},
|
||||
Type: typ,
|
||||
Ip: database.ParseIP(r.RemoteAddr),
|
||||
UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()},
|
||||
|
||||
@@ -90,6 +90,7 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
|
||||
data.logSources,
|
||||
data.templateVersions[0],
|
||||
nil,
|
||||
groupConnectionLogsByWorkspaceAndAgent(data.connectionLogs),
|
||||
)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
@@ -209,6 +210,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
|
||||
data.logSources,
|
||||
data.templateVersions,
|
||||
data.provisionerDaemons,
|
||||
data.connectionLogs,
|
||||
)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
@@ -300,6 +302,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ
|
||||
data.logSources,
|
||||
data.templateVersions[0],
|
||||
data.provisionerDaemons,
|
||||
groupConnectionLogsByWorkspaceAndAgent(data.connectionLogs),
|
||||
)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
@@ -545,6 +548,7 @@ func (api *API) postWorkspaceBuildsInternal(
|
||||
[]database.WorkspaceAgentLogSource{},
|
||||
database.TemplateVersion{},
|
||||
provisionerDaemons,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceBuild{}, httperror.NewResponseError(
|
||||
@@ -977,6 +981,7 @@ type workspaceBuildsData struct {
|
||||
scripts []database.WorkspaceAgentScript
|
||||
logSources []database.WorkspaceAgentLogSource
|
||||
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
|
||||
connectionLogs []database.GetOngoingAgentConnectionsLast24hRow
|
||||
}
|
||||
|
||||
func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []database.WorkspaceBuild) (workspaceBuildsData, error) {
|
||||
@@ -1045,6 +1050,33 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab
|
||||
return workspaceBuildsData{}, xerrors.Errorf("get workspace agents: %w", err)
|
||||
}
|
||||
|
||||
connectionLogs := []database.GetOngoingAgentConnectionsLast24hRow{}
|
||||
if len(workspaceBuilds) > 0 && len(agents) > 0 {
|
||||
workspaceIDSet := make(map[uuid.UUID]struct{}, len(workspaceBuilds))
|
||||
for _, build := range workspaceBuilds {
|
||||
workspaceIDSet[build.WorkspaceID] = struct{}{}
|
||||
}
|
||||
workspaceIDs := make([]uuid.UUID, 0, len(workspaceIDSet))
|
||||
for workspaceID := range workspaceIDSet {
|
||||
workspaceIDs = append(workspaceIDs, workspaceID)
|
||||
}
|
||||
|
||||
agentNameSet := make(map[string]struct{}, len(agents))
|
||||
for _, agent := range agents {
|
||||
agentNameSet[agent.Name] = struct{}{}
|
||||
}
|
||||
agentNames := make([]string, 0, len(agentNameSet))
|
||||
for agentName := range agentNameSet {
|
||||
agentNames = append(agentNames, agentName)
|
||||
}
|
||||
|
||||
// nolint:gocritic // Intentionally visible to any authorized workspace reader.
|
||||
connectionLogs, err = getOngoingAgentConnectionsLast24h(dbauthz.AsSystemRestricted(ctx), api.Database, workspaceIDs, agentNames)
|
||||
if err != nil {
|
||||
return workspaceBuildsData{}, xerrors.Errorf("get ongoing agent connections: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(resources) == 0 {
|
||||
return workspaceBuildsData{
|
||||
jobs: jobs,
|
||||
@@ -1108,6 +1140,7 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab
|
||||
appStatuses: statuses,
|
||||
scripts: scripts,
|
||||
logSources: logSources,
|
||||
connectionLogs: connectionLogs,
|
||||
provisionerDaemons: pendingJobProvisioners,
|
||||
}, nil
|
||||
}
|
||||
@@ -1125,6 +1158,7 @@ func (api *API) convertWorkspaceBuilds(
|
||||
agentLogSources []database.WorkspaceAgentLogSource,
|
||||
templateVersions []database.TemplateVersion,
|
||||
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow,
|
||||
connectionLogs []database.GetOngoingAgentConnectionsLast24hRow,
|
||||
) ([]codersdk.WorkspaceBuild, error) {
|
||||
workspaceByID := map[uuid.UUID]database.Workspace{}
|
||||
for _, workspace := range workspaces {
|
||||
@@ -1138,6 +1172,7 @@ func (api *API) convertWorkspaceBuilds(
|
||||
for _, templateVersion := range templateVersions {
|
||||
templateVersionByID[templateVersion.ID] = templateVersion
|
||||
}
|
||||
connectionLogsByWorkspaceAndAgent := groupConnectionLogsByWorkspaceAndAgent(connectionLogs)
|
||||
|
||||
// Should never be nil for API consistency
|
||||
apiBuilds := []codersdk.WorkspaceBuild{}
|
||||
@@ -1168,6 +1203,7 @@ func (api *API) convertWorkspaceBuilds(
|
||||
agentLogSources,
|
||||
templateVersion,
|
||||
provisionerDaemons,
|
||||
connectionLogsByWorkspaceAndAgent,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("converting workspace build: %w", err)
|
||||
@@ -1192,6 +1228,7 @@ func (api *API) convertWorkspaceBuild(
|
||||
agentLogSources []database.WorkspaceAgentLogSource,
|
||||
templateVersion database.TemplateVersion,
|
||||
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow,
|
||||
connectionLogsByWorkspaceAndAgent map[uuid.UUID]map[string][]database.GetOngoingAgentConnectionsLast24hRow,
|
||||
) (codersdk.WorkspaceBuild, error) {
|
||||
resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{}
|
||||
for _, resource := range workspaceResources {
|
||||
@@ -1259,6 +1296,23 @@ func (api *API) convertWorkspaceBuild(
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceBuild{}, xerrors.Errorf("converting workspace agent: %w", err)
|
||||
}
|
||||
var agentLogs []database.GetOngoingAgentConnectionsLast24hRow
|
||||
if connectionLogsByWorkspaceAndAgent != nil {
|
||||
if byAgent, ok := connectionLogsByWorkspaceAndAgent[build.WorkspaceID]; ok {
|
||||
agentLogs = byAgent[agent.Name]
|
||||
}
|
||||
}
|
||||
// nolint:gocritic // Reading tailnet peering events requires
|
||||
// coordinator context.
|
||||
peeringEvents, err := api.Database.GetAllTailnetPeeringEventsByPeerID(dbauthz.AsTailnetCoordinator(api.ctx), uuid.NullUUID{UUID: agent.ID, Valid: true})
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceBuild{}, xerrors.Errorf("getting tailnet peering events: %w", err)
|
||||
}
|
||||
|
||||
peerTelemetry := api.PeerNetworkTelemetryStore.GetAll(agent.ID)
|
||||
if sessions := mergeWorkspaceConnectionsIntoSessions(agent.ID, peeringEvents, agentLogs, api.DERPMap(), peerTelemetry); len(sessions) > 0 {
|
||||
apiAgent.Sessions = sessions
|
||||
}
|
||||
apiAgents = append(apiAgents, apiAgent)
|
||||
}
|
||||
metadata := append(make([]database.WorkspaceResourceMetadatum, 0), metadataByResourceID[resource.ID]...)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
gProto "google.golang.org/protobuf/proto"
|
||||
tailcfg "tailscale.com/tailcfg"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
workspaceAgentConnectionsPerAgentLimit int64 = 50
|
||||
workspaceAgentConnectionsWindow time.Duration = 24 * time.Hour
|
||||
// Web app connection logs have updated_at bumped on each token refresh
|
||||
// (~1/min for HTTP apps). Use 1m30s as the activity window.
|
||||
workspaceAppActiveWindow time.Duration = 90 * time.Second
|
||||
)
|
||||
|
||||
var workspaceAgentConnectionsTypes = []database.ConnectionType{
|
||||
database.ConnectionTypeSsh,
|
||||
database.ConnectionTypeVscode,
|
||||
database.ConnectionTypeJetbrains,
|
||||
database.ConnectionTypeReconnectingPty,
|
||||
database.ConnectionTypeWorkspaceApp,
|
||||
database.ConnectionTypePortForwarding,
|
||||
}
|
||||
|
||||
func getOngoingAgentConnectionsLast24h(ctx context.Context, db database.Store, workspaceIDs []uuid.UUID, agentNames []string) ([]database.GetOngoingAgentConnectionsLast24hRow, error) {
|
||||
return db.GetOngoingAgentConnectionsLast24h(ctx, database.GetOngoingAgentConnectionsLast24hParams{
|
||||
WorkspaceIds: workspaceIDs,
|
||||
AgentNames: agentNames,
|
||||
Types: workspaceAgentConnectionsTypes,
|
||||
Since: dbtime.Now().Add(-workspaceAgentConnectionsWindow),
|
||||
AppActiveSince: dbtime.Now().Add(-workspaceAppActiveWindow),
|
||||
PerAgentLimit: workspaceAgentConnectionsPerAgentLimit,
|
||||
})
|
||||
}
|
||||
|
||||
func groupConnectionLogsByWorkspaceAndAgent(logs []database.GetOngoingAgentConnectionsLast24hRow) map[uuid.UUID]map[string][]database.GetOngoingAgentConnectionsLast24hRow {
|
||||
byWorkspaceAndAgent := make(map[uuid.UUID]map[string][]database.GetOngoingAgentConnectionsLast24hRow)
|
||||
for _, l := range logs {
|
||||
byAgent, ok := byWorkspaceAndAgent[l.WorkspaceID]
|
||||
if !ok {
|
||||
byAgent = make(map[string][]database.GetOngoingAgentConnectionsLast24hRow)
|
||||
byWorkspaceAndAgent[l.WorkspaceID] = byAgent
|
||||
}
|
||||
byAgent[l.AgentName] = append(byAgent[l.AgentName], l)
|
||||
}
|
||||
return byWorkspaceAndAgent
|
||||
}
|
||||
|
||||
func connectionFromLog(log database.GetOngoingAgentConnectionsLast24hRow) codersdk.WorkspaceConnection {
|
||||
connectTime := log.ConnectTime
|
||||
var ip *netip.Addr
|
||||
if log.Ip.Valid {
|
||||
if addr, ok := netip.AddrFromSlice(log.Ip.IPNet.IP); ok {
|
||||
addr = addr.Unmap()
|
||||
ip = &addr
|
||||
}
|
||||
}
|
||||
conn := codersdk.WorkspaceConnection{
|
||||
IP: ip,
|
||||
Status: codersdk.ConnectionStatusOngoing,
|
||||
CreatedAt: connectTime,
|
||||
ConnectedAt: &connectTime,
|
||||
Type: codersdk.ConnectionType(log.Type),
|
||||
}
|
||||
if log.SlugOrPort.Valid {
|
||||
conn.Detail = log.SlugOrPort.String
|
||||
}
|
||||
if log.ClientHostname.Valid {
|
||||
conn.ClientHostname = log.ClientHostname.String
|
||||
}
|
||||
if log.ShortDescription.Valid {
|
||||
conn.ShortDescription = log.ShortDescription.String
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
type peeringRecord struct {
|
||||
agentID uuid.UUID
|
||||
controlEvents []database.TailnetPeeringEvent
|
||||
connectionLogs []database.GetOngoingAgentConnectionsLast24hRow
|
||||
peerTelemetry *PeerNetworkTelemetry
|
||||
}
|
||||
|
||||
// mergeWorkspaceConnectionsIntoSessions groups ongoing connections into
|
||||
// sessions. Connections are grouped by ClientHostname when available
|
||||
// (so that SSH, Coder Desktop, and IDE connections from the same machine
|
||||
// become one expandable session), falling back to IP when hostname is
|
||||
// unknown. Live sessions don't have session_id yet - they're computed
|
||||
// at query time.
|
||||
//
|
||||
// This function combines three data sources:
|
||||
// - tunnelPeers: live coordinator state for real-time network status
|
||||
// - peeringEvents: DB-persisted control plane events for historical status
|
||||
// - connectionLogs: application-layer connection records (ssh, vscode, etc.)
|
||||
func mergeWorkspaceConnectionsIntoSessions(
|
||||
agentID uuid.UUID,
|
||||
peeringEvents []database.TailnetPeeringEvent,
|
||||
connectionLogs []database.GetOngoingAgentConnectionsLast24hRow,
|
||||
derpMap *tailcfg.DERPMap,
|
||||
peerTelemetry map[uuid.UUID]*PeerNetworkTelemetry,
|
||||
) []codersdk.WorkspaceSession {
|
||||
if len(peeringEvents) == 0 && len(connectionLogs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build flat connections using peering events and tunnel peers.
|
||||
connections := mergeConnectionsFlat(agentID, peeringEvents, connectionLogs, derpMap, peerTelemetry)
|
||||
|
||||
// Group by ClientHostname when available, otherwise by IP.
|
||||
// This ensures connections from the same machine (e.g. SSH +
|
||||
// Coder Desktop + IDE) collapse into a single session even if
|
||||
// they use different tailnet IPs.
|
||||
groups := make(map[string][]codersdk.WorkspaceConnection)
|
||||
|
||||
for _, conn := range connections {
|
||||
var key string
|
||||
if conn.ClientHostname != "" {
|
||||
key = "host:" + conn.ClientHostname
|
||||
} else if conn.IP != nil {
|
||||
key = "ip:" + conn.IP.String()
|
||||
}
|
||||
groups[key] = append(groups[key], conn)
|
||||
}
|
||||
|
||||
// Convert to sessions.
|
||||
var sessions []codersdk.WorkspaceSession
|
||||
for _, conns := range groups {
|
||||
if len(conns) == 0 {
|
||||
continue
|
||||
}
|
||||
sessions = append(sessions, codersdk.WorkspaceSession{
|
||||
// No ID for live sessions (ephemeral grouping).
|
||||
IP: conns[0].IP,
|
||||
ClientHostname: conns[0].ClientHostname,
|
||||
ShortDescription: conns[0].ShortDescription,
|
||||
Status: deriveSessionStatus(conns),
|
||||
StartedAt: earliestTime(conns),
|
||||
Connections: conns,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort sessions by hostname first, then IP for stable ordering.
|
||||
slices.SortFunc(sessions, func(a, b codersdk.WorkspaceSession) int {
|
||||
if c := cmp.Compare(a.ClientHostname, b.ClientHostname); c != 0 {
|
||||
return c
|
||||
}
|
||||
aIP, bIP := "", ""
|
||||
if a.IP != nil {
|
||||
aIP = a.IP.String()
|
||||
}
|
||||
if b.IP != nil {
|
||||
bIP = b.IP.String()
|
||||
}
|
||||
return cmp.Compare(aIP, bIP)
|
||||
})
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
// mergeConnectionsFlat combines coordinator tunnel peers, DB peering events,
|
||||
// and connection logs into a unified view. Tunnel peers provide real-time
|
||||
// network status, peering events provide persisted control plane history,
|
||||
// and connection logs provide the application-layer type (ssh, vscode, etc.).
|
||||
// Entries are correlated by tailnet IP address.
|
||||
func mergeConnectionsFlat(
|
||||
agentID uuid.UUID,
|
||||
peeringEvents []database.TailnetPeeringEvent,
|
||||
connectionLogs []database.GetOngoingAgentConnectionsLast24hRow,
|
||||
derpMap *tailcfg.DERPMap,
|
||||
peerTelemetry map[uuid.UUID]*PeerNetworkTelemetry,
|
||||
) []codersdk.WorkspaceConnection {
|
||||
agentAddr := tailnet.CoderServicePrefix.AddrFromUUID(agentID)
|
||||
|
||||
// Build peering records from DB events, keyed by peering ID.
|
||||
peeringRecords := make(map[string]*peeringRecord)
|
||||
for _, pe := range peeringEvents {
|
||||
record, ok := peeringRecords[string(pe.PeeringID)]
|
||||
if !ok {
|
||||
record = &peeringRecord{
|
||||
agentID: agentID,
|
||||
}
|
||||
peeringRecords[string(pe.PeeringID)] = record
|
||||
}
|
||||
record.controlEvents = append(record.controlEvents, pe)
|
||||
}
|
||||
|
||||
var connections []codersdk.WorkspaceConnection
|
||||
|
||||
for _, log := range connectionLogs {
|
||||
if !log.Ip.Valid {
|
||||
connections = append(connections, connectionFromLog(log))
|
||||
continue
|
||||
}
|
||||
clientIP, ok := netip.AddrFromSlice(log.Ip.IPNet.IP)
|
||||
if !ok || clientIP.Is4() {
|
||||
connections = append(connections, connectionFromLog(log))
|
||||
continue
|
||||
}
|
||||
peeringID := tailnet.PeeringIDFromAddrs(agentAddr, clientIP)
|
||||
record, ok := peeringRecords[string(peeringID)]
|
||||
if !ok {
|
||||
record = &peeringRecord{
|
||||
agentID: agentID,
|
||||
}
|
||||
peeringRecords[string(peeringID)] = record
|
||||
}
|
||||
record.connectionLogs = append(record.connectionLogs, log)
|
||||
}
|
||||
|
||||
// Apply network telemetry per peer to ongoing connections.
|
||||
for clientID, peerTelemetry := range peerTelemetry {
|
||||
peeringID := tailnet.PeeringIDFromUUIDs(agentID, clientID)
|
||||
record, ok := peeringRecords[string(peeringID)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
record.peerTelemetry = peerTelemetry
|
||||
}
|
||||
|
||||
for _, record := range peeringRecords {
|
||||
connections = append(connections, connectionFromRecord(record, derpMap))
|
||||
}
|
||||
|
||||
// Sort by creation time
|
||||
slices.SortFunc(connections, func(a, b codersdk.WorkspaceConnection) int {
|
||||
return b.CreatedAt.Compare(a.CreatedAt) // Newest first.
|
||||
})
|
||||
|
||||
return connections
|
||||
}
|
||||
|
||||
func connectionFromRecord(record *peeringRecord, derpMap *tailcfg.DERPMap) codersdk.WorkspaceConnection {
|
||||
slices.SortFunc(record.controlEvents, func(a, b database.TailnetPeeringEvent) int {
|
||||
return a.OccurredAt.Compare(b.OccurredAt)
|
||||
})
|
||||
slices.SortFunc(record.connectionLogs, func(a, b database.GetOngoingAgentConnectionsLast24hRow) int {
|
||||
return a.ConnectTime.Compare(b.ConnectTime)
|
||||
})
|
||||
conn := codersdk.WorkspaceConnection{
|
||||
Status: codersdk.ConnectionStatusOngoing,
|
||||
}
|
||||
for _, ce := range record.controlEvents {
|
||||
if conn.CreatedAt.IsZero() {
|
||||
conn.CreatedAt = ce.OccurredAt
|
||||
}
|
||||
switch ce.EventType {
|
||||
case database.TailnetPeeringEventTypePeerUpdateLost:
|
||||
conn.Status = codersdk.ConnectionStatusControlLost
|
||||
case database.TailnetPeeringEventTypePeerUpdateDisconnected:
|
||||
conn.Status = codersdk.ConnectionStatusCleanDisconnected
|
||||
conn.EndedAt = &ce.OccurredAt
|
||||
case database.TailnetPeeringEventTypeRemovedTunnel:
|
||||
conn.Status = codersdk.ConnectionStatusCleanDisconnected
|
||||
conn.EndedAt = &ce.OccurredAt
|
||||
case database.TailnetPeeringEventTypeAddedTunnel:
|
||||
clientIP := tailnet.CoderServicePrefix.AddrFromUUID(ce.SrcPeerID.UUID)
|
||||
conn.IP = &clientIP
|
||||
case database.TailnetPeeringEventTypePeerUpdateNode:
|
||||
if ce.SrcPeerID.Valid && ce.SrcPeerID.UUID != record.agentID && ce.Node != nil {
|
||||
pNode := new(proto.Node)
|
||||
if err := gProto.Unmarshal(ce.Node, pNode); err == nil {
|
||||
conn.ClientHostname = pNode.Hostname
|
||||
conn.ShortDescription = pNode.ShortDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, log := range record.connectionLogs {
|
||||
if conn.CreatedAt.IsZero() {
|
||||
conn.CreatedAt = log.ConnectTime
|
||||
}
|
||||
if log.Ip.Valid {
|
||||
if addr, ok := netip.AddrFromSlice(log.Ip.IPNet.IP); ok {
|
||||
addr = addr.Unmap()
|
||||
conn.IP = &addr
|
||||
}
|
||||
}
|
||||
if log.SlugOrPort.Valid {
|
||||
conn.Detail = log.SlugOrPort.String
|
||||
}
|
||||
if log.Type.Valid() {
|
||||
conn.Type = codersdk.ConnectionType(log.Type)
|
||||
}
|
||||
if conn.Status != codersdk.ConnectionStatusControlLost &&
|
||||
conn.Status != codersdk.ConnectionStatusCleanDisconnected && log.DisconnectTime.Valid {
|
||||
conn.Status = codersdk.ConnectionStatusClientDisconnected
|
||||
}
|
||||
if conn.EndedAt == nil && log.DisconnectTime.Valid {
|
||||
conn.EndedAt = &log.DisconnectTime.Time
|
||||
}
|
||||
}
|
||||
if record.peerTelemetry == nil {
|
||||
return conn
|
||||
}
|
||||
if record.peerTelemetry.P2P != nil {
|
||||
p2p := *record.peerTelemetry.P2P
|
||||
conn.P2P = &p2p
|
||||
}
|
||||
if record.peerTelemetry.HomeDERP > 0 {
|
||||
regionID := record.peerTelemetry.HomeDERP
|
||||
name := fmt.Sprintf("Unnamed %d", regionID)
|
||||
if derpMap != nil {
|
||||
if region, ok := derpMap.Regions[regionID]; ok && region != nil && region.RegionName != "" {
|
||||
name = region.RegionName
|
||||
}
|
||||
}
|
||||
conn.HomeDERP = &codersdk.WorkspaceConnectionHomeDERP{
|
||||
ID: regionID,
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
if record.peerTelemetry.P2P != nil && *record.peerTelemetry.P2P && record.peerTelemetry.P2PLatency != nil {
|
||||
ms := float64(*record.peerTelemetry.P2PLatency) / float64(time.Millisecond)
|
||||
conn.LatencyMS = &ms
|
||||
} else if record.peerTelemetry.DERPLatency != nil {
|
||||
ms := float64(*record.peerTelemetry.DERPLatency) / float64(time.Millisecond)
|
||||
conn.LatencyMS = &ms
|
||||
}
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func deriveSessionStatus(conns []codersdk.WorkspaceConnection) codersdk.WorkspaceConnectionStatus {
|
||||
for _, c := range conns {
|
||||
if c.Status == codersdk.ConnectionStatusOngoing {
|
||||
return codersdk.ConnectionStatusOngoing
|
||||
}
|
||||
}
|
||||
if len(conns) > 0 {
|
||||
return conns[0].Status
|
||||
}
|
||||
return codersdk.ConnectionStatusCleanDisconnected
|
||||
}
|
||||
|
||||
func earliestTime(conns []codersdk.WorkspaceConnection) time.Time {
|
||||
if len(conns) == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
earliest := conns[0].CreatedAt
|
||||
for _, c := range conns[1:] {
|
||||
if c.CreatedAt.Before(earliest) {
|
||||
earliest = c.CreatedAt
|
||||
}
|
||||
}
|
||||
return earliest
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestWorkspaceAgentConnections_FromConnectionLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
})
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// One active SSH connection should be returned.
|
||||
sshConnID := uuid.New()
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-1 * time.Minute),
|
||||
OrganizationID: r.Workspace.OrganizationID,
|
||||
WorkspaceOwnerID: r.Workspace.OwnerID,
|
||||
WorkspaceID: r.Workspace.ID,
|
||||
WorkspaceName: r.Workspace.Name,
|
||||
AgentName: r.Agents[0].Name,
|
||||
Type: database.ConnectionTypeSsh,
|
||||
ConnectionID: uuid.NullUUID{UUID: sshConnID, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
|
||||
// A web-ish connection type should be ignored.
|
||||
// Use a time outside the 5-minute activity window so this
|
||||
// localhost web connection is treated as stale and filtered out.
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-10 * time.Minute),
|
||||
OrganizationID: r.Workspace.OrganizationID,
|
||||
WorkspaceOwnerID: r.Workspace.OwnerID,
|
||||
WorkspaceID: r.Workspace.ID,
|
||||
WorkspaceName: r.Workspace.Name,
|
||||
AgentName: r.Agents[0].Name,
|
||||
Type: database.ConnectionTypeWorkspaceApp,
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
UserID: uuid.NullUUID{UUID: user.UserID, Valid: true},
|
||||
UserAgent: sql.NullString{String: "Mozilla/5.0", Valid: true},
|
||||
SlugOrPort: sql.NullString{String: "code-server", Valid: true},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
workspace, err := client.Workspace(ctx, r.Workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, workspace.LatestBuild.Resources)
|
||||
require.NotEmpty(t, workspace.LatestBuild.Resources[0].Agents)
|
||||
|
||||
agent := workspace.LatestBuild.Resources[0].Agents[0]
|
||||
require.Equal(t, r.Agents[0].Name, agent.Name)
|
||||
require.Len(t, agent.Sessions, 1)
|
||||
require.Equal(t, codersdk.ConnectionStatusOngoing, agent.Sessions[0].Status)
|
||||
require.NotEmpty(t, agent.Sessions[0].Connections)
|
||||
require.Equal(t, codersdk.ConnectionTypeSSH, agent.Sessions[0].Connections[0].Type)
|
||||
require.NotNil(t, agent.Sessions[0].IP)
|
||||
require.Equal(t, "127.0.0.1", agent.Sessions[0].IP.String())
|
||||
|
||||
apiAgent, err := client.WorkspaceAgent(ctx, agent.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, apiAgent.Sessions, 1)
|
||||
require.Equal(t, codersdk.ConnectionTypeSSH, apiAgent.Sessions[0].Connections[0].Type)
|
||||
}
|
||||
@@ -857,6 +857,7 @@ func createWorkspace(
|
||||
[]database.WorkspaceAgentLogSource{},
|
||||
database.TemplateVersion{},
|
||||
provisionerDaemons,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||||
@@ -2579,6 +2580,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa
|
||||
data.logSources,
|
||||
data.templateVersions,
|
||||
data.provisionerDaemons,
|
||||
data.connectionLogs,
|
||||
)
|
||||
if err != nil {
|
||||
return workspaceData{}, xerrors.Errorf("convert workspace builds: %w", err)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// @Summary Get workspace sessions
|
||||
// @ID get-workspace-sessions
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Workspaces
|
||||
// @Produce json
|
||||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||||
// @Param limit query int false "Page limit"
|
||||
// @Param offset query int false "Page offset"
|
||||
// @Success 200 {object} codersdk.WorkspaceSessionsResponse
|
||||
// @Router /workspaces/{workspace}/sessions [get]
|
||||
func (api *API) workspaceSessions(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspace := httpmw.WorkspaceParam(r)
|
||||
|
||||
// Parse pagination from query params.
|
||||
queryParams := httpapi.NewQueryParamParser()
|
||||
limit := queryParams.Int(r.URL.Query(), 25, "limit")
|
||||
offset := queryParams.Int(r.URL.Query(), 0, "offset")
|
||||
if len(queryParams.Errors) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid query parameters.",
|
||||
Validations: queryParams.Errors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch sessions. Use AsSystemRestricted because the user is
|
||||
// already authorized to access the workspace via route
|
||||
// middleware; the ResourceConnectionLog RBAC check would
|
||||
// incorrectly reject regular workspace owners.
|
||||
//nolint:gocritic // Workspace access is verified by middleware.
|
||||
sysCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
sessions, err := api.Database.GetWorkspaceSessionsOffset(sysCtx, database.GetWorkspaceSessionsOffsetParams{
|
||||
WorkspaceID: workspace.ID,
|
||||
LimitCount: int32(limit), //nolint:gosec // query param is validated and bounded
|
||||
OffsetCount: int32(offset), //nolint:gosec // query param is validated and bounded
|
||||
StartedAfter: time.Time{},
|
||||
StartedBefore: time.Time{},
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching sessions.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get total count for pagination.
|
||||
//nolint:gocritic // Workspace access is verified by middleware.
|
||||
totalCount, err := api.Database.CountWorkspaceSessions(sysCtx, database.CountWorkspaceSessionsParams{
|
||||
WorkspaceID: workspace.ID,
|
||||
StartedAfter: time.Time{},
|
||||
StartedBefore: time.Time{},
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error counting sessions.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch connections for all sessions in one query.
|
||||
sessionIDs := make([]uuid.UUID, len(sessions))
|
||||
for i, s := range sessions {
|
||||
sessionIDs[i] = s.ID
|
||||
}
|
||||
|
||||
var connections []database.ConnectionLog
|
||||
if len(sessionIDs) > 0 {
|
||||
//nolint:gocritic // Workspace access is verified by middleware.
|
||||
connections, err = api.Database.GetConnectionLogsBySessionIDs(sysCtx, sessionIDs)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching connections.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Group connections by session_id.
|
||||
connsBySession := make(map[uuid.UUID][]database.ConnectionLog)
|
||||
for _, conn := range connections {
|
||||
if conn.SessionID.Valid {
|
||||
connsBySession[conn.SessionID.UUID] = append(connsBySession[conn.SessionID.UUID], conn)
|
||||
}
|
||||
}
|
||||
|
||||
// Build response with nested connections.
|
||||
response := codersdk.WorkspaceSessionsResponse{
|
||||
Sessions: make([]codersdk.WorkspaceSession, len(sessions)),
|
||||
Count: totalCount,
|
||||
}
|
||||
for i, s := range sessions {
|
||||
response.Sessions[i] = ConvertDBSessionToSDK(s, connsBySession[s.ID])
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ConvertDBSessionToSDK converts a database workspace session row and its
|
||||
// connection logs into the SDK representation.
|
||||
func ConvertDBSessionToSDK(s database.GetWorkspaceSessionsOffsetRow, connections []database.ConnectionLog) codersdk.WorkspaceSession {
|
||||
id := s.ID
|
||||
session := codersdk.WorkspaceSession{
|
||||
ID: &id,
|
||||
Status: codersdk.ConnectionStatusCleanDisconnected, // Historic sessions are disconnected.
|
||||
StartedAt: s.StartedAt,
|
||||
EndedAt: &s.EndedAt,
|
||||
Connections: make([]codersdk.WorkspaceConnection, len(connections)),
|
||||
}
|
||||
|
||||
// Parse IP.
|
||||
if s.Ip.Valid {
|
||||
if addr, ok := netip.AddrFromSlice(s.Ip.IPNet.IP); ok {
|
||||
addr = addr.Unmap()
|
||||
session.IP = &addr
|
||||
}
|
||||
}
|
||||
|
||||
if s.ClientHostname.Valid {
|
||||
session.ClientHostname = s.ClientHostname.String
|
||||
}
|
||||
if s.ShortDescription.Valid {
|
||||
session.ShortDescription = s.ShortDescription.String
|
||||
}
|
||||
|
||||
for i, conn := range connections {
|
||||
session.Connections[i] = ConvertConnectionLogToSDK(conn)
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// ConvertConnectionLogToSDK converts a database connection log into the
|
||||
// SDK representation used within workspace sessions.
|
||||
func ConvertConnectionLogToSDK(conn database.ConnectionLog) codersdk.WorkspaceConnection {
|
||||
wc := codersdk.WorkspaceConnection{
|
||||
Status: codersdk.ConnectionStatusCleanDisconnected,
|
||||
CreatedAt: conn.ConnectTime,
|
||||
Type: codersdk.ConnectionType(conn.Type),
|
||||
}
|
||||
|
||||
// Parse IP.
|
||||
if conn.Ip.Valid {
|
||||
if addr, ok := netip.AddrFromSlice(conn.Ip.IPNet.IP); ok {
|
||||
addr = addr.Unmap()
|
||||
wc.IP = &addr
|
||||
}
|
||||
}
|
||||
|
||||
if conn.SlugOrPort.Valid {
|
||||
wc.Detail = conn.SlugOrPort.String
|
||||
}
|
||||
|
||||
if conn.DisconnectTime.Valid {
|
||||
wc.EndedAt = &conn.DisconnectTime.Time
|
||||
}
|
||||
|
||||
if conn.DisconnectReason.Valid {
|
||||
wc.DisconnectReason = conn.DisconnectReason.String
|
||||
}
|
||||
if conn.Code.Valid {
|
||||
code := conn.Code.Int32
|
||||
wc.ExitCode = &code
|
||||
}
|
||||
if conn.UserAgent.Valid {
|
||||
wc.UserAgent = conn.UserAgent.String
|
||||
}
|
||||
if conn.ClientHostname.Valid {
|
||||
wc.ClientHostname = conn.ClientHostname.String
|
||||
}
|
||||
if conn.ShortDescription.Valid {
|
||||
wc.ShortDescription = conn.ShortDescription.String
|
||||
}
|
||||
|
||||
return wc
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestWorkspaceSessions_EmptyResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
})
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.WorkspaceSessions(ctx, r.Workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, resp.Sessions)
|
||||
require.Equal(t, int64(0), resp.Count)
|
||||
}
|
||||
|
||||
func TestWorkspaceSessions_WithHistoricSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
|
||||
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
})
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// Insert two connected SSH connections from the same IP.
|
||||
connID1 := uuid.New()
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-30 * time.Minute),
|
||||
OrganizationID: r.Workspace.OrganizationID,
|
||||
WorkspaceOwnerID: r.Workspace.OwnerID,
|
||||
WorkspaceID: r.Workspace.ID,
|
||||
WorkspaceName: r.Workspace.Name,
|
||||
AgentName: r.Agents[0].Name,
|
||||
Type: database.ConnectionTypeSsh,
|
||||
ConnectionID: uuid.NullUUID{UUID: connID1, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
|
||||
connID2 := uuid.New()
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-25 * time.Minute),
|
||||
OrganizationID: r.Workspace.OrganizationID,
|
||||
WorkspaceOwnerID: r.Workspace.OwnerID,
|
||||
WorkspaceID: r.Workspace.ID,
|
||||
WorkspaceName: r.Workspace.Name,
|
||||
AgentName: r.Agents[0].Name,
|
||||
Type: database.ConnectionTypeSsh,
|
||||
ConnectionID: uuid.NullUUID{UUID: connID2, Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
|
||||
// Close the connections and create sessions atomically.
|
||||
closedAt := now.Add(-5 * time.Minute)
|
||||
_, err := db.CloseConnectionLogsAndCreateSessions(context.Background(), database.CloseConnectionLogsAndCreateSessionsParams{
|
||||
ClosedAt: sql.NullTime{Time: closedAt, Valid: true},
|
||||
Reason: sql.NullString{String: "workspace stopped", Valid: true},
|
||||
WorkspaceID: r.Workspace.ID,
|
||||
Types: []database.ConnectionType{database.ConnectionTypeSsh},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.WorkspaceSessions(ctx, r.Workspace.ID)
|
||||
require.NoError(t, err)
|
||||
// CloseConnectionLogsAndCreateSessions groups by IP, so both
|
||||
// connections from 127.0.0.1 should be in a single session.
|
||||
require.Equal(t, int64(1), resp.Count)
|
||||
require.Len(t, resp.Sessions, 1)
|
||||
require.NotNil(t, resp.Sessions[0].IP)
|
||||
require.Equal(t, "127.0.0.1", resp.Sessions[0].IP.String())
|
||||
require.Equal(t, codersdk.ConnectionStatusCleanDisconnected, resp.Sessions[0].Status)
|
||||
require.Len(t, resp.Sessions[0].Connections, 2)
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentConnections_LiveSessionGrouping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
})
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// Two ongoing SSH connections from the same IP (127.0.0.1, the
|
||||
// default in dbgen).
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-2 * time.Minute),
|
||||
OrganizationID: r.Workspace.OrganizationID,
|
||||
WorkspaceOwnerID: r.Workspace.OwnerID,
|
||||
WorkspaceID: r.Workspace.ID,
|
||||
WorkspaceName: r.Workspace.Name,
|
||||
AgentName: r.Agents[0].Name,
|
||||
Type: database.ConnectionTypeSsh,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-1 * time.Minute),
|
||||
OrganizationID: r.Workspace.OrganizationID,
|
||||
WorkspaceOwnerID: r.Workspace.OwnerID,
|
||||
WorkspaceID: r.Workspace.ID,
|
||||
WorkspaceName: r.Workspace.Name,
|
||||
AgentName: r.Agents[0].Name,
|
||||
Type: database.ConnectionTypeSsh,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
})
|
||||
|
||||
// One ongoing SSH connection from a different IP (10.0.0.1).
|
||||
_ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{
|
||||
Time: now.Add(-1 * time.Minute),
|
||||
OrganizationID: r.Workspace.OrganizationID,
|
||||
WorkspaceOwnerID: r.Workspace.OwnerID,
|
||||
WorkspaceID: r.Workspace.ID,
|
||||
WorkspaceName: r.Workspace.Name,
|
||||
AgentName: r.Agents[0].Name,
|
||||
Type: database.ConnectionTypeSsh,
|
||||
ConnectionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ConnectionStatus: database.ConnectionStatusConnected,
|
||||
Ip: pqtype.Inet{
|
||||
IPNet: net.IPNet{
|
||||
IP: net.IPv4(10, 0, 0, 1),
|
||||
Mask: net.IPv4Mask(255, 255, 255, 255),
|
||||
},
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
workspace, err := client.Workspace(ctx, r.Workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, workspace.LatestBuild.Resources)
|
||||
require.NotEmpty(t, workspace.LatestBuild.Resources[0].Agents)
|
||||
|
||||
agent := workspace.LatestBuild.Resources[0].Agents[0]
|
||||
require.Len(t, agent.Sessions, 2)
|
||||
|
||||
// Find which session is which by IP.
|
||||
var session127, session10 codersdk.WorkspaceSession
|
||||
for _, s := range agent.Sessions {
|
||||
require.NotNil(t, s.IP)
|
||||
switch s.IP.String() {
|
||||
case "127.0.0.1":
|
||||
session127 = s
|
||||
case "10.0.0.1":
|
||||
session10 = s
|
||||
default:
|
||||
t.Fatalf("unexpected session IP: %s", s.IP.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The 127.0.0.1 session should have 2 connections.
|
||||
require.Len(t, session127.Connections, 2)
|
||||
require.Equal(t, codersdk.ConnectionStatusOngoing, session127.Status)
|
||||
|
||||
// The 10.0.0.1 session should have 1 connection.
|
||||
require.Len(t, session10.Connections, 1)
|
||||
require.Equal(t, codersdk.ConnectionStatusOngoing, session10.Status)
|
||||
}
|
||||
@@ -45,10 +45,11 @@ type WorkspaceEvent struct {
|
||||
type WorkspaceEventKind string
|
||||
|
||||
const (
|
||||
WorkspaceEventKindStateChange WorkspaceEventKind = "state_change"
|
||||
WorkspaceEventKindStatsUpdate WorkspaceEventKind = "stats_update"
|
||||
WorkspaceEventKindMetadataUpdate WorkspaceEventKind = "mtd_update"
|
||||
WorkspaceEventKindAppHealthUpdate WorkspaceEventKind = "app_health"
|
||||
WorkspaceEventKindStateChange WorkspaceEventKind = "state_change"
|
||||
WorkspaceEventKindStatsUpdate WorkspaceEventKind = "stats_update"
|
||||
WorkspaceEventKindMetadataUpdate WorkspaceEventKind = "mtd_update"
|
||||
WorkspaceEventKindAppHealthUpdate WorkspaceEventKind = "app_health"
|
||||
WorkspaceEventKindConnectionLogUpdate WorkspaceEventKind = "connection_log_update"
|
||||
|
||||
WorkspaceEventKindAgentLifecycleUpdate WorkspaceEventKind = "agt_lifecycle_update"
|
||||
WorkspaceEventKindAgentConnectionUpdate WorkspaceEventKind = "agt_connection_update"
|
||||
|
||||
@@ -46,6 +46,7 @@ const (
|
||||
ConnectionTypeReconnectingPTY ConnectionType = "reconnecting_pty"
|
||||
ConnectionTypeWorkspaceApp ConnectionType = "workspace_app"
|
||||
ConnectionTypePortForwarding ConnectionType = "port_forwarding"
|
||||
ConnectionTypeSystem ConnectionType = "system"
|
||||
)
|
||||
|
||||
// ConnectionLogStatus is the status of a connection log entry.
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// UserDiagnosticResponse is the top-level response from the operator
|
||||
// diagnostic endpoint for a single user.
|
||||
type UserDiagnosticResponse struct {
|
||||
User DiagnosticUser `json:"user"`
|
||||
GeneratedAt time.Time `json:"generated_at" format:"date-time"`
|
||||
TimeWindow DiagnosticTimeWindow `json:"time_window"`
|
||||
Summary DiagnosticSummary `json:"summary"`
|
||||
CurrentConnections []DiagnosticConnection `json:"current_connections"`
|
||||
Workspaces []DiagnosticWorkspace `json:"workspaces"`
|
||||
Patterns []DiagnosticPattern `json:"patterns"`
|
||||
}
|
||||
|
||||
// DiagnosticUser identifies the user being diagnosed.
|
||||
type DiagnosticUser struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
LastSeenAt time.Time `json:"last_seen_at" format:"date-time"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
}
|
||||
|
||||
// DiagnosticTimeWindow describes the time range covered by the diagnostic.
|
||||
type DiagnosticTimeWindow struct {
|
||||
Start time.Time `json:"start" format:"date-time"`
|
||||
End time.Time `json:"end" format:"date-time"`
|
||||
Hours int `json:"hours"`
|
||||
}
|
||||
|
||||
// DiagnosticSummary aggregates connection statistics across the time window.
|
||||
type DiagnosticSummary struct {
|
||||
TotalSessions int `json:"total_sessions"`
|
||||
TotalConnections int `json:"total_connections"`
|
||||
ActiveConnections int `json:"active_connections"`
|
||||
ByStatus DiagnosticStatusBreakdown `json:"by_status"`
|
||||
ByType map[string]int `json:"by_type"`
|
||||
Network DiagnosticNetworkSummary `json:"network"`
|
||||
Headline string `json:"headline"`
|
||||
}
|
||||
|
||||
// DiagnosticStatusBreakdown counts sessions by their terminal status.
|
||||
type DiagnosticStatusBreakdown struct {
|
||||
Ongoing int `json:"ongoing"`
|
||||
Clean int `json:"clean"`
|
||||
Lost int `json:"lost"`
|
||||
WorkspaceStopped int `json:"workspace_stopped"`
|
||||
WorkspaceDeleted int `json:"workspace_deleted"`
|
||||
}
|
||||
|
||||
// DiagnosticNetworkSummary contains aggregate network quality metrics.
|
||||
type DiagnosticNetworkSummary struct {
|
||||
P2PConnections int `json:"p2p_connections"`
|
||||
DERPConnections int `json:"derp_connections"`
|
||||
AvgLatencyMS *float64 `json:"avg_latency_ms"`
|
||||
P95LatencyMS *float64 `json:"p95_latency_ms"`
|
||||
PrimaryDERPRegion *string `json:"primary_derp_region"`
|
||||
}
|
||||
|
||||
// DiagnosticConnection describes a single live or historical connection.
|
||||
type DiagnosticConnection struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
|
||||
WorkspaceName string `json:"workspace_name"`
|
||||
AgentID uuid.UUID `json:"agent_id" format:"uuid"`
|
||||
AgentName string `json:"agent_name"`
|
||||
IP string `json:"ip"`
|
||||
ClientHostname string `json:"client_hostname"`
|
||||
ShortDescription string `json:"short_description"`
|
||||
Type ConnectionType `json:"type"`
|
||||
Detail string `json:"detail"`
|
||||
Status WorkspaceConnectionStatus `json:"status"`
|
||||
StartedAt time.Time `json:"started_at" format:"date-time"`
|
||||
P2P *bool `json:"p2p"`
|
||||
LatencyMS *float64 `json:"latency_ms"`
|
||||
HomeDERP *DiagnosticHomeDERP `json:"home_derp"`
|
||||
Explanation string `json:"explanation"`
|
||||
}
|
||||
|
||||
// DiagnosticHomeDERP identifies a DERP relay region.
|
||||
type DiagnosticHomeDERP struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// DiagnosticWorkspace groups sessions for a single workspace.
|
||||
type DiagnosticWorkspace struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Name string `json:"name"`
|
||||
OwnerUsername string `json:"owner_username"`
|
||||
Status string `json:"status"`
|
||||
TemplateName string `json:"template_name"`
|
||||
TemplateDisplayName string `json:"template_display_name"`
|
||||
Health DiagnosticHealth `json:"health"`
|
||||
HealthReason string `json:"health_reason"`
|
||||
Sessions []DiagnosticSession `json:"sessions"`
|
||||
}
|
||||
|
||||
// DiagnosticHealth represents workspace health status.
|
||||
type DiagnosticHealth string
|
||||
|
||||
const (
|
||||
DiagnosticHealthHealthy DiagnosticHealth = "healthy"
|
||||
DiagnosticHealthDegraded DiagnosticHealth = "degraded"
|
||||
DiagnosticHealthUnhealthy DiagnosticHealth = "unhealthy"
|
||||
DiagnosticHealthInactive DiagnosticHealth = "inactive"
|
||||
)
|
||||
|
||||
// DiagnosticSession represents a client session with one or more connections.
|
||||
type DiagnosticSession struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
|
||||
WorkspaceName string `json:"workspace_name"`
|
||||
AgentName string `json:"agent_name"`
|
||||
IP string `json:"ip"`
|
||||
ClientHostname string `json:"client_hostname"`
|
||||
ShortDescription string `json:"short_description"`
|
||||
StartedAt time.Time `json:"started_at" format:"date-time"`
|
||||
EndedAt *time.Time `json:"ended_at" format:"date-time"`
|
||||
DurationSeconds *float64 `json:"duration_seconds"`
|
||||
Status WorkspaceConnectionStatus `json:"status"`
|
||||
DisconnectReason string `json:"disconnect_reason"`
|
||||
Explanation string `json:"explanation"`
|
||||
Network DiagnosticSessionNetwork `json:"network"`
|
||||
Connections []DiagnosticSessionConn `json:"connections"`
|
||||
Timeline []DiagnosticTimelineEvent `json:"timeline"`
|
||||
}
|
||||
|
||||
// DiagnosticSessionNetwork holds per-session network quality info.
|
||||
type DiagnosticSessionNetwork struct {
|
||||
P2P *bool `json:"p2p"`
|
||||
AvgLatencyMS *float64 `json:"avg_latency_ms"`
|
||||
HomeDERP *string `json:"home_derp"`
|
||||
}
|
||||
|
||||
// DiagnosticSessionConn represents a single connection within a session.
|
||||
type DiagnosticSessionConn struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Type ConnectionType `json:"type"`
|
||||
Detail string `json:"detail"`
|
||||
ConnectedAt time.Time `json:"connected_at" format:"date-time"`
|
||||
DisconnectedAt *time.Time `json:"disconnected_at" format:"date-time"`
|
||||
Status WorkspaceConnectionStatus `json:"status"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
Explanation string `json:"explanation"`
|
||||
}
|
||||
|
||||
// DiagnosticTimelineEventKind enumerates timeline event types.
|
||||
type DiagnosticTimelineEventKind string
|
||||
|
||||
const (
|
||||
DiagnosticTimelineEventTunnelCreated DiagnosticTimelineEventKind = "tunnel_created"
|
||||
DiagnosticTimelineEventTunnelRemoved DiagnosticTimelineEventKind = "tunnel_removed"
|
||||
DiagnosticTimelineEventNodeUpdate DiagnosticTimelineEventKind = "node_update"
|
||||
DiagnosticTimelineEventPeerDisconnected DiagnosticTimelineEventKind = "peer_disconnected"
|
||||
DiagnosticTimelineEventPeerLost DiagnosticTimelineEventKind = "peer_lost"
|
||||
DiagnosticTimelineEventPeerRecovered DiagnosticTimelineEventKind = "peer_recovered"
|
||||
DiagnosticTimelineEventConnectionOpened DiagnosticTimelineEventKind = "connection_opened"
|
||||
DiagnosticTimelineEventConnectionClosed DiagnosticTimelineEventKind = "connection_closed"
|
||||
DiagnosticTimelineEventDERPFallback DiagnosticTimelineEventKind = "derp_fallback"
|
||||
DiagnosticTimelineEventP2PEstablished DiagnosticTimelineEventKind = "p2p_established"
|
||||
DiagnosticTimelineEventLatencySpike DiagnosticTimelineEventKind = "latency_spike"
|
||||
DiagnosticTimelineEventWorkspaceStateChange DiagnosticTimelineEventKind = "workspace_state_change"
|
||||
)
|
||||
|
||||
// DiagnosticTimelineEvent records a point-in-time event within a session.
|
||||
type DiagnosticTimelineEvent struct {
|
||||
Timestamp time.Time `json:"timestamp" format:"date-time"`
|
||||
Kind DiagnosticTimelineEventKind `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Severity ConnectionDiagnosticSeverity `json:"severity"`
|
||||
}
|
||||
|
||||
// ConnectionDiagnosticSeverity represents event or pattern severity.
|
||||
type ConnectionDiagnosticSeverity string
|
||||
|
||||
const (
|
||||
ConnectionDiagnosticSeverityInfo ConnectionDiagnosticSeverity = "info"
|
||||
ConnectionDiagnosticSeverityWarning ConnectionDiagnosticSeverity = "warning"
|
||||
ConnectionDiagnosticSeverityError ConnectionDiagnosticSeverity = "error"
|
||||
ConnectionDiagnosticSeverityCritical ConnectionDiagnosticSeverity = "critical"
|
||||
)
|
||||
|
||||
// DiagnosticPatternType enumerates recognized connection patterns.
|
||||
type DiagnosticPatternType string
|
||||
|
||||
const (
|
||||
DiagnosticPatternDeviceSleep DiagnosticPatternType = "device_sleep"
|
||||
DiagnosticPatternWorkspaceAutostart DiagnosticPatternType = "workspace_autostart"
|
||||
DiagnosticPatternNetworkPolicy DiagnosticPatternType = "network_policy"
|
||||
DiagnosticPatternAgentCrash DiagnosticPatternType = "agent_crash"
|
||||
DiagnosticPatternLatencyDegradation DiagnosticPatternType = "latency_degradation"
|
||||
DiagnosticPatternDERPFallback DiagnosticPatternType = "derp_fallback"
|
||||
DiagnosticPatternCleanUsage DiagnosticPatternType = "clean_usage"
|
||||
DiagnosticPatternUnknownDrops DiagnosticPatternType = "unknown_drops"
|
||||
)
|
||||
|
||||
// DiagnosticPattern describes a detected pattern across sessions.
|
||||
type DiagnosticPattern struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Type DiagnosticPatternType `json:"type"`
|
||||
Severity ConnectionDiagnosticSeverity `json:"severity"`
|
||||
AffectedSessions int `json:"affected_sessions"`
|
||||
TotalSessions int `json:"total_sessions"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Commonalities DiagnosticPatternCommonality `json:"commonalities"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
}
|
||||
|
||||
// DiagnosticPatternCommonality captures shared attributes of affected sessions.
|
||||
type DiagnosticPatternCommonality struct {
|
||||
ConnectionTypes []string `json:"connection_types"`
|
||||
ClientDescriptions []string `json:"client_descriptions"`
|
||||
DurationRange *DiagnosticDurationRange `json:"duration_range"`
|
||||
DisconnectReasons []string `json:"disconnect_reasons"`
|
||||
TimeOfDayRange *string `json:"time_of_day_range"`
|
||||
}
|
||||
|
||||
// DiagnosticDurationRange is a min/max pair of seconds.
|
||||
type DiagnosticDurationRange struct {
|
||||
MinSeconds float64 `json:"min_seconds"`
|
||||
MaxSeconds float64 `json:"max_seconds"`
|
||||
}
|
||||
|
||||
// UserDiagnostic fetches the operator diagnostic report for a user.
|
||||
func (c *Client) UserDiagnostic(ctx context.Context, username string, hours int) (UserDiagnosticResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/v2/connectionlog/diagnostics/%s", username),
|
||||
nil,
|
||||
func(r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
q.Set("hours", strconv.Itoa(hours))
|
||||
r.URL.RawQuery = q.Encode()
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return UserDiagnosticResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return UserDiagnosticResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp UserDiagnosticResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
@@ -100,7 +100,7 @@ Examples:
|
||||
ctx, cancel := context.WithTimeoutCause(ctx, 5*time.Minute, xerrors.New("MCP handler timeout after 5 min"))
|
||||
defer cancel()
|
||||
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace, "AI tool call - bash")
|
||||
if err != nil {
|
||||
return WorkspaceBashResult{}, err
|
||||
}
|
||||
|
||||
@@ -1444,7 +1444,7 @@ var WorkspaceLS = Tool[WorkspaceLSArgs, WorkspaceLSResponse]{
|
||||
},
|
||||
UserClientOptional: true,
|
||||
Handler: func(ctx context.Context, deps Deps, args WorkspaceLSArgs) (WorkspaceLSResponse, error) {
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace, "AI tool call - ls")
|
||||
if err != nil {
|
||||
return WorkspaceLSResponse{}, err
|
||||
}
|
||||
@@ -1509,7 +1509,7 @@ var WorkspaceReadFile = Tool[WorkspaceReadFileArgs, WorkspaceReadFileResponse]{
|
||||
},
|
||||
UserClientOptional: true,
|
||||
Handler: func(ctx context.Context, deps Deps, args WorkspaceReadFileArgs) (WorkspaceReadFileResponse, error) {
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace, "AI tool call - read file")
|
||||
if err != nil {
|
||||
return WorkspaceReadFileResponse{}, err
|
||||
}
|
||||
@@ -1582,7 +1582,7 @@ content you are trying to write, then re-encode it properly.
|
||||
},
|
||||
UserClientOptional: true,
|
||||
Handler: func(ctx context.Context, deps Deps, args WorkspaceWriteFileArgs) (codersdk.Response, error) {
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace, "AI tool call - write file")
|
||||
if err != nil {
|
||||
return codersdk.Response{}, err
|
||||
}
|
||||
@@ -1644,7 +1644,7 @@ var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{
|
||||
},
|
||||
UserClientOptional: true,
|
||||
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFileArgs) (codersdk.Response, error) {
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace, "AI tool call - edit file")
|
||||
if err != nil {
|
||||
return codersdk.Response{}, err
|
||||
}
|
||||
@@ -1721,7 +1721,7 @@ var WorkspaceEditFiles = Tool[WorkspaceEditFilesArgs, codersdk.Response]{
|
||||
},
|
||||
UserClientOptional: true,
|
||||
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFilesArgs) (codersdk.Response, error) {
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace, "AI tool call - edit files")
|
||||
if err != nil {
|
||||
return codersdk.Response{}, err
|
||||
}
|
||||
@@ -2156,7 +2156,7 @@ func NormalizeWorkspaceInput(input string) string {
|
||||
|
||||
// newAgentConn returns a connection to the agent specified by the workspace,
|
||||
// which must be in the format [owner/]workspace[.agent].
|
||||
func newAgentConn(ctx context.Context, client *codersdk.Client, workspace string) (workspacesdk.AgentConn, error) {
|
||||
func newAgentConn(ctx context.Context, client *codersdk.Client, workspace string, shortDescription string) (workspacesdk.AgentConn, error) {
|
||||
workspaceName := NormalizeWorkspaceInput(workspace)
|
||||
_, workspaceAgent, err := findWorkspaceAndAgent(ctx, client, workspaceName)
|
||||
if err != nil {
|
||||
@@ -2176,7 +2176,8 @@ func newAgentConn(ctx context.Context, client *codersdk.Client, workspace string
|
||||
wsClient := workspacesdk.New(client)
|
||||
|
||||
conn, err := wsClient.DialAgent(ctx, workspaceAgent.ID, &workspacesdk.DialAgentOptions{
|
||||
BlockEndpoints: false,
|
||||
BlockEndpoints: false,
|
||||
ShortDescription: shortDescription,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to dial agent: %w", err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -176,8 +177,72 @@ type WorkspaceAgent struct {
|
||||
// of the `coder_script` resource. It's only referenced by old clients.
|
||||
// Deprecated: Remove in the future!
|
||||
StartupScriptBehavior WorkspaceAgentStartupScriptBehavior `json:"startup_script_behavior"`
|
||||
|
||||
Sessions []WorkspaceSession `json:"sessions,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspaceConnectionHomeDERP identifies the DERP relay region
|
||||
// used as the agent's home relay.
|
||||
type WorkspaceConnectionHomeDERP struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type WorkspaceConnection struct {
|
||||
IP *netip.Addr `json:"ip,omitempty"`
|
||||
Status WorkspaceConnectionStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
ConnectedAt *time.Time `json:"connected_at,omitempty" format:"date-time"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty" format:"date-time"`
|
||||
Type ConnectionType `json:"type"`
|
||||
// Detail is the app slug or port number for workspace_app and port_forwarding connections.
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// ClientHostname is the hostname of the client that connected to the agent. Self-reported by the client.
|
||||
ClientHostname string `json:"client_hostname,omitempty"`
|
||||
// ShortDescription is the human-readable short description of the connection. Self-reported by the client.
|
||||
ShortDescription string `json:"short_description,omitempty"`
|
||||
// P2P indicates a direct peer-to-peer connection (true) or
|
||||
// DERP relay (false). Nil if telemetry unavailable.
|
||||
P2P *bool `json:"p2p,omitempty"`
|
||||
// LatencyMS is the most recent round-trip latency in
|
||||
// milliseconds. Uses P2P latency when direct, DERP otherwise.
|
||||
LatencyMS *float64 `json:"latency_ms,omitempty"`
|
||||
// HomeDERP is the DERP region metadata for the agent's home relay.
|
||||
HomeDERP *WorkspaceConnectionHomeDERP `json:"home_derp,omitempty"`
|
||||
// DisconnectReason is the reason the connection was closed.
|
||||
DisconnectReason string `json:"disconnect_reason,omitempty"`
|
||||
// ExitCode is the exit code of the SSH session.
|
||||
ExitCode *int32 `json:"exit_code,omitempty"`
|
||||
// UserAgent is the HTTP user agent string from web connections.
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspaceSession represents a client's session containing one or more connections.
|
||||
// Live sessions are grouped by IP at query time; historic sessions have a database ID.
|
||||
type WorkspaceSession struct {
|
||||
ID *uuid.UUID `json:"id,omitempty"` // nil for live sessions
|
||||
IP *netip.Addr `json:"ip,omitempty"`
|
||||
ClientHostname string `json:"client_hostname,omitempty"`
|
||||
ShortDescription string `json:"short_description,omitempty"`
|
||||
Status WorkspaceConnectionStatus `json:"status"`
|
||||
StartedAt time.Time `json:"started_at" format:"date-time"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty" format:"date-time"`
|
||||
Connections []WorkspaceConnection `json:"connections"`
|
||||
}
|
||||
|
||||
type WorkspaceConnectionStatus string
|
||||
|
||||
const (
|
||||
// ConnectionStatusOngoing is the status of a connection that has started but not finished yet.
|
||||
ConnectionStatusOngoing WorkspaceConnectionStatus = "ongoing"
|
||||
// ConnectionStatusControlLost is a connection where we lost contact with the client at the Tailnet Coordinator
|
||||
ConnectionStatusControlLost WorkspaceConnectionStatus = "control_lost"
|
||||
// ConnectionStatusClientDisconnected is a connection where the client disconnected without a clean Tailnet Coordinator disconnect.
|
||||
ConnectionStatusClientDisconnected WorkspaceConnectionStatus = "client_disconnected"
|
||||
// ConnectionStatusCleanDisconnected is a connection that cleanly disconnected at the Tailnet Coordinator and client
|
||||
ConnectionStatusCleanDisconnected WorkspaceConnectionStatus = "clean_disconnected"
|
||||
)
|
||||
|
||||
type WorkspaceAgentLogSource struct {
|
||||
WorkspaceAgentID uuid.UUID `json:"workspace_agent_id" format:"uuid"`
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
|
||||
@@ -175,6 +175,7 @@ func (c *agentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, w
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
c.SendConnectedTelemetry(c.agentAddress(), tailnet.TelemetryApplicationReconnectingPTY)
|
||||
conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentReconnectingPTYPort))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -290,6 +291,8 @@ func (c *agentConn) DialContext(ctx context.Context, network string, addr string
|
||||
port, _ := strconv.ParseUint(rawPort, 10, 16)
|
||||
ipp := netip.AddrPortFrom(c.agentAddress(), uint16(port))
|
||||
|
||||
c.SendConnectedTelemetry(c.agentAddress(), tailnet.TelemetryApplicationPortForward)
|
||||
|
||||
switch network {
|
||||
case "tcp":
|
||||
return c.Conn.DialContextTCP(ctx, ipp)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -188,6 +189,8 @@ type DialAgentOptions struct {
|
||||
// Whether the client will send network telemetry events.
|
||||
// Enable instead of Disable so it's initialized to false (in tests).
|
||||
EnableTelemetry bool
|
||||
// ShortDescription is the human-readable short description of the connection.
|
||||
ShortDescription string
|
||||
}
|
||||
|
||||
// RewriteDERPMap rewrites the DERP map to use the configured access URL of the
|
||||
@@ -236,9 +239,40 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *
|
||||
dialer := NewWebsocketDialer(options.Logger, coordinateURL, wsOptions)
|
||||
clk := quartz.NewReal()
|
||||
controller := tailnet.NewController(options.Logger, dialer)
|
||||
controller.ResumeTokenCtrl = tailnet.NewBasicResumeTokenController(options.Logger, clk)
|
||||
resumeTokenCtrl := tailnet.NewBasicResumeTokenController(options.Logger, clk)
|
||||
controller.ResumeTokenCtrl = resumeTokenCtrl
|
||||
|
||||
// Pre-dial to obtain a server-assigned PeerID. This opens a temporary
|
||||
// DRPC connection, calls RefreshResumeToken (which now returns the
|
||||
// PeerID), then closes. The resume token is seeded into the controller
|
||||
// so subsequent dials reuse the same PeerID.
|
||||
var addresses []netip.Prefix
|
||||
var connID uuid.UUID
|
||||
tokenResp, err := c.preDialPeerID(dialCtx, coordinateURL, wsOptions, options.Logger)
|
||||
if err != nil {
|
||||
// Graceful fallback: if the pre-dial fails (e.g. old server),
|
||||
// use a random IP address like before.
|
||||
options.Logger.Warn(dialCtx, "failed to pre-dial for peer ID, falling back to random address", slog.Error(err))
|
||||
ip := tailnet.CoderServicePrefix.RandomAddr()
|
||||
addresses = []netip.Prefix{netip.PrefixFrom(ip, 128)}
|
||||
} else {
|
||||
peerID, parseErr := uuid.FromBytes(tokenResp.PeerId)
|
||||
if parseErr != nil || peerID == uuid.Nil {
|
||||
// Server returned a response without a PeerID (old server).
|
||||
options.Logger.Warn(dialCtx, "server did not return peer ID, falling back to random address")
|
||||
ip := tailnet.TailscaleServicePrefix.RandomAddr()
|
||||
addresses = []netip.Prefix{netip.PrefixFrom(ip, 128)}
|
||||
} else {
|
||||
connID = peerID
|
||||
addresses = []netip.Prefix{
|
||||
tailnet.CoderServicePrefix.PrefixFromUUID(peerID),
|
||||
}
|
||||
resumeTokenCtrl.SetInitialToken(tokenResp)
|
||||
options.Logger.Debug(dialCtx, "obtained server-assigned peer ID",
|
||||
slog.F("peer_id", peerID.String()))
|
||||
}
|
||||
}
|
||||
|
||||
ip := tailnet.TailscaleServicePrefix.RandomAddr()
|
||||
var header http.Header
|
||||
if headerTransport, ok := c.client.HTTPClient.Transport.(*codersdk.HeaderTransport); ok {
|
||||
header = headerTransport.Header
|
||||
@@ -252,7 +286,8 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *
|
||||
|
||||
c.RewriteDERPMap(connInfo.DERPMap)
|
||||
conn, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)},
|
||||
ID: connID,
|
||||
Addresses: addresses,
|
||||
DERPMap: connInfo.DERPMap,
|
||||
DERPHeader: &header,
|
||||
DERPForceWebSockets: connInfo.DERPForceWebSockets,
|
||||
@@ -261,6 +296,7 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *
|
||||
CaptureHook: options.CaptureHook,
|
||||
ClientType: proto.TelemetryEvent_CLI,
|
||||
TelemetrySink: telemetrySink,
|
||||
ShortDescription: options.ShortDescription,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create tailnet: %w", err)
|
||||
@@ -306,6 +342,47 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *
|
||||
return agentConn, nil
|
||||
}
|
||||
|
||||
// preDialPeerID opens a temporary DRPC connection to the coordinate endpoint
|
||||
// and calls RefreshResumeToken to obtain the server-assigned PeerID and an
|
||||
// initial resume token. The connection is closed before returning. This
|
||||
// allows the caller to derive tailnet IP addresses from the PeerID before
|
||||
// creating the tailnet.Conn.
|
||||
func (*Client) preDialPeerID(
|
||||
ctx context.Context,
|
||||
coordinateURL *url.URL,
|
||||
wsOptions *websocket.DialOptions,
|
||||
logger slog.Logger,
|
||||
) (*proto.RefreshResumeTokenResponse, error) {
|
||||
u := new(url.URL)
|
||||
*u = *coordinateURL
|
||||
q := u.Query()
|
||||
// Use version 2.0 for the pre-dial. RefreshResumeToken was added in
|
||||
// 2.3 but fails gracefully as "unimplemented" on older servers.
|
||||
q.Add("version", "2.0")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
// nolint:bodyclose
|
||||
ws, _, err := websocket.Dial(ctx, u.String(), wsOptions)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("pre-dial websocket: %w", err)
|
||||
}
|
||||
defer ws.Close(websocket.StatusNormalClosure, "pre-dial complete")
|
||||
|
||||
client, err := tailnet.NewDRPCClient(
|
||||
websocket.NetConn(ctx, ws, websocket.MessageBinary),
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("pre-dial DRPC client: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.RefreshResumeToken(ctx, &proto.RefreshResumeTokenRequest{})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("pre-dial RefreshResumeToken: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// @typescript-ignore:WorkspaceAgentReconnectingPTYOpts
|
||||
type WorkspaceAgentReconnectingPTYOpts struct {
|
||||
AgentID uuid.UUID
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// WorkspaceSessionsResponse is the response for listing workspace sessions.
|
||||
type WorkspaceSessionsResponse struct {
|
||||
Sessions []WorkspaceSession `json:"sessions"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// WorkspaceSessions returns the sessions for a workspace.
|
||||
func (c *Client) WorkspaceSessions(ctx context.Context, workspaceID uuid.UUID) (WorkspaceSessionsResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/sessions", workspaceID), nil)
|
||||
if err != nil {
|
||||
return WorkspaceSessionsResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return WorkspaceSessionsResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp WorkspaceSessionsResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// GlobalWorkspaceSession extends WorkspaceSession with workspace
|
||||
// metadata for the global sessions view.
|
||||
type GlobalWorkspaceSession struct {
|
||||
WorkspaceSession
|
||||
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
|
||||
WorkspaceName string `json:"workspace_name"`
|
||||
WorkspaceOwnerUsername string `json:"workspace_owner_username"`
|
||||
}
|
||||
|
||||
// GlobalWorkspaceSessionsResponse is the response for the global
|
||||
// workspace sessions endpoint.
|
||||
type GlobalWorkspaceSessionsResponse struct {
|
||||
Sessions []GlobalWorkspaceSession `json:"sessions"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// GlobalWorkspaceSessionsRequest is the request for the global
|
||||
// workspace sessions endpoint.
|
||||
type GlobalWorkspaceSessionsRequest struct {
|
||||
SearchQuery string `json:"q,omitempty"`
|
||||
Pagination
|
||||
}
|
||||
|
||||
// GlobalWorkspaceSessions returns workspace sessions across all
|
||||
// workspaces, with optional search filters.
|
||||
func (c *Client) GlobalWorkspaceSessions(ctx context.Context, req GlobalWorkspaceSessionsRequest) (GlobalWorkspaceSessionsResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/connectionlog/sessions", nil, req.Pagination.asRequestOption(), func(r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
var params []string
|
||||
if req.SearchQuery != "" {
|
||||
params = append(params, req.SearchQuery)
|
||||
}
|
||||
q.Set("q", strings.Join(params, " "))
|
||||
r.URL.RawQuery = q.Encode()
|
||||
})
|
||||
if err != nil {
|
||||
return GlobalWorkspaceSessionsResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return GlobalWorkspaceSessionsResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var resp GlobalWorkspaceSessionsResponse
|
||||
err = json.NewDecoder(res.Body).Decode(&resp)
|
||||
if err != nil {
|
||||
return GlobalWorkspaceSessionsResponse{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
Generated
+33
@@ -631,6 +631,39 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent} \
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
|
||||
Generated
+263
-15
@@ -191,6 +191,39 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -431,6 +464,39 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -790,6 +856,39 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/res
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -914,6 +1013,32 @@ Status Code **200**
|
||||
| `»»» script` | string | false | | |
|
||||
| `»»» start_blocks_login` | boolean | false | | |
|
||||
| `»»» timeout` | integer | false | | |
|
||||
| `»» sessions` | array | false | | |
|
||||
| `»»» client_hostname` | string | false | | |
|
||||
| `»»» connections` | array | false | | |
|
||||
| `»»»» client_hostname` | string | false | | Client hostname is the hostname of the client that connected to the agent. Self-reported by the client. |
|
||||
| `»»»» connected_at` | string(date-time) | false | | |
|
||||
| `»»»» created_at` | string(date-time) | false | | |
|
||||
| `»»»» detail` | string | false | | Detail is the app slug or port number for workspace_app and port_forwarding connections. |
|
||||
| `»»»» disconnect_reason` | string | false | | Disconnect reason is the reason the connection was closed. |
|
||||
| `»»»» ended_at` | string(date-time) | false | | |
|
||||
| `»»»» exit_code` | integer | false | | Exit code is the exit code of the SSH session. |
|
||||
| `»»»» home_derp` | [codersdk.WorkspaceConnectionHomeDERP](schemas.md#codersdkworkspaceconnectionhomederp) | false | | Home derp is the DERP region metadata for the agent's home relay. |
|
||||
| `»»»»» id` | integer | false | | |
|
||||
| `»»»»» name` | string | false | | |
|
||||
| `»»»» ip` | string | false | | |
|
||||
| `»»»» latency_ms` | number | false | | Latency ms is the most recent round-trip latency in milliseconds. Uses P2P latency when direct, DERP otherwise. |
|
||||
| `»»»» p2p` | boolean | false | | P2p indicates a direct peer-to-peer connection (true) or DERP relay (false). Nil if telemetry unavailable. |
|
||||
| `»»»» short_description` | string | false | | Short description is the human-readable short description of the connection. Self-reported by the client. |
|
||||
| `»»»» status` | [codersdk.WorkspaceConnectionStatus](schemas.md#codersdkworkspaceconnectionstatus) | false | | |
|
||||
| `»»»» type` | [codersdk.ConnectionType](schemas.md#codersdkconnectiontype) | false | | |
|
||||
| `»»»» user_agent` | string | false | | User agent is the HTTP user agent string from web connections. |
|
||||
| `»»» ended_at` | string(date-time) | false | | |
|
||||
| `»»» id` | string | false | | nil for live sessions |
|
||||
| `»»» ip` | string | false | | |
|
||||
| `»»» short_description` | string | false | | |
|
||||
| `»»» started_at` | string(date-time) | false | | |
|
||||
| `»»» status` | [codersdk.WorkspaceConnectionStatus](schemas.md#codersdkworkspaceconnectionstatus) | false | | |
|
||||
| `»» started_at` | string(date-time) | false | | |
|
||||
| `»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | Startup script behavior is a legacy field that is deprecated in favor of the `coder_script` resource. It's only referenced by old clients. Deprecated: Remove in the future! |
|
||||
| `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | |
|
||||
@@ -944,8 +1069,9 @@ Status Code **200**
|
||||
| `sharing_level` | `authenticated`, `organization`, `owner`, `public` |
|
||||
| `state` | `complete`, `failure`, `idle`, `working` |
|
||||
| `lifecycle_state` | `created`, `off`, `ready`, `shutdown_error`, `shutdown_timeout`, `shutting_down`, `start_error`, `start_timeout`, `starting` |
|
||||
| `status` | `clean_disconnected`, `client_disconnected`, `connected`, `connecting`, `control_lost`, `disconnected`, `ongoing`, `timeout` |
|
||||
| `type` | `jetbrains`, `port_forwarding`, `reconnecting_pty`, `ssh`, `system`, `vscode`, `workspace_app` |
|
||||
| `startup_script_behavior` | `blocking`, `non-blocking` |
|
||||
| `status` | `connected`, `connecting`, `disconnected`, `timeout` |
|
||||
| `workspace_transition` | `delete`, `start`, `stop` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
@@ -1139,6 +1265,39 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -1490,6 +1649,36 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -1676,6 +1865,32 @@ Status Code **200**
|
||||
| `»»»» script` | string | false | | |
|
||||
| `»»»» start_blocks_login` | boolean | false | | |
|
||||
| `»»»» timeout` | integer | false | | |
|
||||
| `»»» sessions` | array | false | | |
|
||||
| `»»»» client_hostname` | string | false | | |
|
||||
| `»»»» connections` | array | false | | |
|
||||
| `»»»»» client_hostname` | string | false | | Client hostname is the hostname of the client that connected to the agent. Self-reported by the client. |
|
||||
| `»»»»» connected_at` | string(date-time) | false | | |
|
||||
| `»»»»» created_at` | string(date-time) | false | | |
|
||||
| `»»»»» detail` | string | false | | Detail is the app slug or port number for workspace_app and port_forwarding connections. |
|
||||
| `»»»»» disconnect_reason` | string | false | | Disconnect reason is the reason the connection was closed. |
|
||||
| `»»»»» ended_at` | string(date-time) | false | | |
|
||||
| `»»»»» exit_code` | integer | false | | Exit code is the exit code of the SSH session. |
|
||||
| `»»»»» home_derp` | [codersdk.WorkspaceConnectionHomeDERP](schemas.md#codersdkworkspaceconnectionhomederp) | false | | Home derp is the DERP region metadata for the agent's home relay. |
|
||||
| `»»»»»» id` | integer | false | | |
|
||||
| `»»»»»» name` | string | false | | |
|
||||
| `»»»»» ip` | string | false | | |
|
||||
| `»»»»» latency_ms` | number | false | | Latency ms is the most recent round-trip latency in milliseconds. Uses P2P latency when direct, DERP otherwise. |
|
||||
| `»»»»» p2p` | boolean | false | | P2p indicates a direct peer-to-peer connection (true) or DERP relay (false). Nil if telemetry unavailable. |
|
||||
| `»»»»» short_description` | string | false | | Short description is the human-readable short description of the connection. Self-reported by the client. |
|
||||
| `»»»»» status` | [codersdk.WorkspaceConnectionStatus](schemas.md#codersdkworkspaceconnectionstatus) | false | | |
|
||||
| `»»»»» type` | [codersdk.ConnectionType](schemas.md#codersdkconnectiontype) | false | | |
|
||||
| `»»»»» user_agent` | string | false | | User agent is the HTTP user agent string from web connections. |
|
||||
| `»»»» ended_at` | string(date-time) | false | | |
|
||||
| `»»»» id` | string | false | | nil for live sessions |
|
||||
| `»»»» ip` | string | false | | |
|
||||
| `»»»» short_description` | string | false | | |
|
||||
| `»»»» started_at` | string(date-time) | false | | |
|
||||
| `»»»» status` | [codersdk.WorkspaceConnectionStatus](schemas.md#codersdkworkspaceconnectionstatus) | false | | |
|
||||
| `»»» started_at` | string(date-time) | false | | |
|
||||
| `»»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | Startup script behavior is a legacy field that is deprecated in favor of the `coder_script` resource. It's only referenced by old clients. Deprecated: Remove in the future! |
|
||||
| `»»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | |
|
||||
@@ -1710,20 +1925,20 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `error_code` | `REQUIRED_TEMPLATE_VARIABLES` |
|
||||
| `status` | `canceled`, `canceling`, `connected`, `connecting`, `deleted`, `deleting`, `disconnected`, `failed`, `pending`, `running`, `starting`, `stopped`, `stopping`, `succeeded`, `timeout` |
|
||||
| `type` | `template_version_dry_run`, `template_version_import`, `workspace_build` |
|
||||
| `reason` | `autostart`, `autostop`, `initiator` |
|
||||
| `health` | `disabled`, `healthy`, `initializing`, `unhealthy` |
|
||||
| `open_in` | `slim-window`, `tab` |
|
||||
| `sharing_level` | `authenticated`, `organization`, `owner`, `public` |
|
||||
| `state` | `complete`, `failure`, `idle`, `working` |
|
||||
| `lifecycle_state` | `created`, `off`, `ready`, `shutdown_error`, `shutdown_timeout`, `shutting_down`, `start_error`, `start_timeout`, `starting` |
|
||||
| `startup_script_behavior` | `blocking`, `non-blocking` |
|
||||
| `workspace_transition` | `delete`, `start`, `stop` |
|
||||
| `transition` | `delete`, `start`, `stop` |
|
||||
| Property | Value(s) |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `error_code` | `REQUIRED_TEMPLATE_VARIABLES` |
|
||||
| `status` | `canceled`, `canceling`, `clean_disconnected`, `client_disconnected`, `connected`, `connecting`, `control_lost`, `deleted`, `deleting`, `disconnected`, `failed`, `ongoing`, `pending`, `running`, `starting`, `stopped`, `stopping`, `succeeded`, `timeout` |
|
||||
| `type` | `jetbrains`, `port_forwarding`, `reconnecting_pty`, `ssh`, `system`, `template_version_dry_run`, `template_version_import`, `vscode`, `workspace_app`, `workspace_build` |
|
||||
| `reason` | `autostart`, `autostop`, `initiator` |
|
||||
| `health` | `disabled`, `healthy`, `initializing`, `unhealthy` |
|
||||
| `open_in` | `slim-window`, `tab` |
|
||||
| `sharing_level` | `authenticated`, `organization`, `owner`, `public` |
|
||||
| `state` | `complete`, `failure`, `idle`, `working` |
|
||||
| `lifecycle_state` | `created`, `off`, `ready`, `shutdown_error`, `shutdown_timeout`, `shutting_down`, `start_error`, `start_timeout`, `starting` |
|
||||
| `startup_script_behavior` | `blocking`, `non-blocking` |
|
||||
| `workspace_transition` | `delete`, `start`, `stop` |
|
||||
| `transition` | `delete`, `start`, `stop` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -1941,6 +2156,39 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
|
||||
Generated
+263
@@ -301,6 +301,269 @@ curl -X GET http://coder-server:8080/api/v2/connectionlog?limit=0 \
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get user diagnostic report
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/connectionlog/diagnostics/{username} \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /connectionlog/diagnostics/{username}`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------------|-------|---------|----------|------------------------------------------|
|
||||
| `username` | path | string | true | Username |
|
||||
| `hours` | query | integer | false | Hours to look back (default 72, max 168) |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"current_connections": [
|
||||
{
|
||||
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
|
||||
"agent_name": "string",
|
||||
"client_hostname": "string",
|
||||
"detail": "string",
|
||||
"explanation": "string",
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
|
||||
"workspace_name": "string"
|
||||
}
|
||||
],
|
||||
"generated_at": "2019-08-24T14:15:22Z",
|
||||
"patterns": [
|
||||
{
|
||||
"affected_sessions": 0,
|
||||
"commonalities": {
|
||||
"client_descriptions": [
|
||||
"string"
|
||||
],
|
||||
"connection_types": [
|
||||
"string"
|
||||
],
|
||||
"disconnect_reasons": [
|
||||
"string"
|
||||
],
|
||||
"duration_range": {
|
||||
"max_seconds": 0,
|
||||
"min_seconds": 0
|
||||
},
|
||||
"time_of_day_range": "string"
|
||||
},
|
||||
"description": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"recommendation": "string",
|
||||
"severity": "info",
|
||||
"title": "string",
|
||||
"total_sessions": 0,
|
||||
"type": "device_sleep"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"active_connections": 0,
|
||||
"by_status": {
|
||||
"clean": 0,
|
||||
"lost": 0,
|
||||
"ongoing": 0,
|
||||
"workspace_deleted": 0,
|
||||
"workspace_stopped": 0
|
||||
},
|
||||
"by_type": {
|
||||
"property1": 0,
|
||||
"property2": 0
|
||||
},
|
||||
"headline": "string",
|
||||
"network": {
|
||||
"avg_latency_ms": 0,
|
||||
"derp_connections": 0,
|
||||
"p2p_connections": 0,
|
||||
"p95_latency_ms": 0,
|
||||
"primary_derp_region": "string"
|
||||
},
|
||||
"total_connections": 0,
|
||||
"total_sessions": 0
|
||||
},
|
||||
"time_window": {
|
||||
"end": "2019-08-24T14:15:22Z",
|
||||
"hours": 0,
|
||||
"start": "2019-08-24T14:15:22Z"
|
||||
},
|
||||
"user": {
|
||||
"avatar_url": "string",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"email": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"last_seen_at": "2019-08-24T14:15:22Z",
|
||||
"name": "string",
|
||||
"roles": [
|
||||
"string"
|
||||
],
|
||||
"username": "string"
|
||||
},
|
||||
"workspaces": [
|
||||
{
|
||||
"health": "healthy",
|
||||
"health_reason": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"owner_username": "string",
|
||||
"sessions": [
|
||||
{
|
||||
"agent_name": "string",
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnected_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"explanation": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"status": "ongoing",
|
||||
"type": "ssh"
|
||||
}
|
||||
],
|
||||
"disconnect_reason": "string",
|
||||
"duration_seconds": 0,
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"explanation": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"ip": "string",
|
||||
"network": {
|
||||
"avg_latency_ms": 0,
|
||||
"home_derp": "string",
|
||||
"p2p": true
|
||||
},
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing",
|
||||
"timeline": [
|
||||
{
|
||||
"description": "string",
|
||||
"kind": "tunnel_created",
|
||||
"metadata": {
|
||||
"property1": null,
|
||||
"property2": null
|
||||
},
|
||||
"severity": "info",
|
||||
"timestamp": "2019-08-24T14:15:22Z"
|
||||
}
|
||||
],
|
||||
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
|
||||
"workspace_name": "string"
|
||||
}
|
||||
],
|
||||
"status": "string",
|
||||
"template_display_name": "string",
|
||||
"template_name": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserDiagnosticResponse](schemas.md#codersdkuserdiagnosticresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get global workspace sessions
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/connectionlog/sessions?limit=0 \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /connectionlog/sessions`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|----------|-------|---------|----------|--------------|
|
||||
| `q` | query | string | false | Search query |
|
||||
| `limit` | query | integer | true | Page limit |
|
||||
| `offset` | query | integer | false | Page offset |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 0,
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing",
|
||||
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_username": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GlobalWorkspaceSessionsResponse](schemas.md#codersdkglobalworkspacesessionsresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get entitlements
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
+1155
-3
File diff suppressed because it is too large
Load Diff
Generated
+122
-2
@@ -2482,6 +2482,39 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/d
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -2606,6 +2639,32 @@ Status Code **200**
|
||||
| `»»» script` | string | false | | |
|
||||
| `»»» start_blocks_login` | boolean | false | | |
|
||||
| `»»» timeout` | integer | false | | |
|
||||
| `»» sessions` | array | false | | |
|
||||
| `»»» client_hostname` | string | false | | |
|
||||
| `»»» connections` | array | false | | |
|
||||
| `»»»» client_hostname` | string | false | | Client hostname is the hostname of the client that connected to the agent. Self-reported by the client. |
|
||||
| `»»»» connected_at` | string(date-time) | false | | |
|
||||
| `»»»» created_at` | string(date-time) | false | | |
|
||||
| `»»»» detail` | string | false | | Detail is the app slug or port number for workspace_app and port_forwarding connections. |
|
||||
| `»»»» disconnect_reason` | string | false | | Disconnect reason is the reason the connection was closed. |
|
||||
| `»»»» ended_at` | string(date-time) | false | | |
|
||||
| `»»»» exit_code` | integer | false | | Exit code is the exit code of the SSH session. |
|
||||
| `»»»» home_derp` | [codersdk.WorkspaceConnectionHomeDERP](schemas.md#codersdkworkspaceconnectionhomederp) | false | | Home derp is the DERP region metadata for the agent's home relay. |
|
||||
| `»»»»» id` | integer | false | | |
|
||||
| `»»»»» name` | string | false | | |
|
||||
| `»»»» ip` | string | false | | |
|
||||
| `»»»» latency_ms` | number | false | | Latency ms is the most recent round-trip latency in milliseconds. Uses P2P latency when direct, DERP otherwise. |
|
||||
| `»»»» p2p` | boolean | false | | P2p indicates a direct peer-to-peer connection (true) or DERP relay (false). Nil if telemetry unavailable. |
|
||||
| `»»»» short_description` | string | false | | Short description is the human-readable short description of the connection. Self-reported by the client. |
|
||||
| `»»»» status` | [codersdk.WorkspaceConnectionStatus](schemas.md#codersdkworkspaceconnectionstatus) | false | | |
|
||||
| `»»»» type` | [codersdk.ConnectionType](schemas.md#codersdkconnectiontype) | false | | |
|
||||
| `»»»» user_agent` | string | false | | User agent is the HTTP user agent string from web connections. |
|
||||
| `»»» ended_at` | string(date-time) | false | | |
|
||||
| `»»» id` | string | false | | nil for live sessions |
|
||||
| `»»» ip` | string | false | | |
|
||||
| `»»» short_description` | string | false | | |
|
||||
| `»»» started_at` | string(date-time) | false | | |
|
||||
| `»»» status` | [codersdk.WorkspaceConnectionStatus](schemas.md#codersdkworkspaceconnectionstatus) | false | | |
|
||||
| `»» started_at` | string(date-time) | false | | |
|
||||
| `»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | Startup script behavior is a legacy field that is deprecated in favor of the `coder_script` resource. It's only referenced by old clients. Deprecated: Remove in the future! |
|
||||
| `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | |
|
||||
@@ -2636,8 +2695,9 @@ Status Code **200**
|
||||
| `sharing_level` | `authenticated`, `organization`, `owner`, `public` |
|
||||
| `state` | `complete`, `failure`, `idle`, `working` |
|
||||
| `lifecycle_state` | `created`, `off`, `ready`, `shutdown_error`, `shutdown_timeout`, `shutting_down`, `start_error`, `start_timeout`, `starting` |
|
||||
| `status` | `clean_disconnected`, `client_disconnected`, `connected`, `connecting`, `control_lost`, `disconnected`, `ongoing`, `timeout` |
|
||||
| `type` | `jetbrains`, `port_forwarding`, `reconnecting_pty`, `ssh`, `system`, `vscode`, `workspace_app` |
|
||||
| `startup_script_behavior` | `blocking`, `non-blocking` |
|
||||
| `status` | `connected`, `connecting`, `disconnected`, `timeout` |
|
||||
| `workspace_transition` | `delete`, `start`, `stop` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
@@ -3148,6 +3208,39 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/r
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -3272,6 +3365,32 @@ Status Code **200**
|
||||
| `»»» script` | string | false | | |
|
||||
| `»»» start_blocks_login` | boolean | false | | |
|
||||
| `»»» timeout` | integer | false | | |
|
||||
| `»» sessions` | array | false | | |
|
||||
| `»»» client_hostname` | string | false | | |
|
||||
| `»»» connections` | array | false | | |
|
||||
| `»»»» client_hostname` | string | false | | Client hostname is the hostname of the client that connected to the agent. Self-reported by the client. |
|
||||
| `»»»» connected_at` | string(date-time) | false | | |
|
||||
| `»»»» created_at` | string(date-time) | false | | |
|
||||
| `»»»» detail` | string | false | | Detail is the app slug or port number for workspace_app and port_forwarding connections. |
|
||||
| `»»»» disconnect_reason` | string | false | | Disconnect reason is the reason the connection was closed. |
|
||||
| `»»»» ended_at` | string(date-time) | false | | |
|
||||
| `»»»» exit_code` | integer | false | | Exit code is the exit code of the SSH session. |
|
||||
| `»»»» home_derp` | [codersdk.WorkspaceConnectionHomeDERP](schemas.md#codersdkworkspaceconnectionhomederp) | false | | Home derp is the DERP region metadata for the agent's home relay. |
|
||||
| `»»»»» id` | integer | false | | |
|
||||
| `»»»»» name` | string | false | | |
|
||||
| `»»»» ip` | string | false | | |
|
||||
| `»»»» latency_ms` | number | false | | Latency ms is the most recent round-trip latency in milliseconds. Uses P2P latency when direct, DERP otherwise. |
|
||||
| `»»»» p2p` | boolean | false | | P2p indicates a direct peer-to-peer connection (true) or DERP relay (false). Nil if telemetry unavailable. |
|
||||
| `»»»» short_description` | string | false | | Short description is the human-readable short description of the connection. Self-reported by the client. |
|
||||
| `»»»» status` | [codersdk.WorkspaceConnectionStatus](schemas.md#codersdkworkspaceconnectionstatus) | false | | |
|
||||
| `»»»» type` | [codersdk.ConnectionType](schemas.md#codersdkconnectiontype) | false | | |
|
||||
| `»»»» user_agent` | string | false | | User agent is the HTTP user agent string from web connections. |
|
||||
| `»»» ended_at` | string(date-time) | false | | |
|
||||
| `»»» id` | string | false | | nil for live sessions |
|
||||
| `»»» ip` | string | false | | |
|
||||
| `»»» short_description` | string | false | | |
|
||||
| `»»» started_at` | string(date-time) | false | | |
|
||||
| `»»» status` | [codersdk.WorkspaceConnectionStatus](schemas.md#codersdkworkspaceconnectionstatus) | false | | |
|
||||
| `»» started_at` | string(date-time) | false | | |
|
||||
| `»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | Startup script behavior is a legacy field that is deprecated in favor of the `coder_script` resource. It's only referenced by old clients. Deprecated: Remove in the future! |
|
||||
| `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | |
|
||||
@@ -3302,8 +3421,9 @@ Status Code **200**
|
||||
| `sharing_level` | `authenticated`, `organization`, `owner`, `public` |
|
||||
| `state` | `complete`, `failure`, `idle`, `working` |
|
||||
| `lifecycle_state` | `created`, `off`, `ready`, `shutdown_error`, `shutdown_timeout`, `shutting_down`, `start_error`, `start_timeout`, `starting` |
|
||||
| `status` | `clean_disconnected`, `client_disconnected`, `connected`, `connecting`, `control_lost`, `disconnected`, `ongoing`, `timeout` |
|
||||
| `type` | `jetbrains`, `port_forwarding`, `reconnecting_pty`, `ssh`, `system`, `vscode`, `workspace_app` |
|
||||
| `startup_script_behavior` | `blocking`, `non-blocking` |
|
||||
| `status` | `connected`, `connecting`, `disconnected`, `timeout` |
|
||||
| `workspace_transition` | `delete`, `start`, `stop` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
Generated
+234
@@ -246,6 +246,36 @@ of the template will be used.
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -551,6 +581,36 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -881,6 +941,36 @@ of the template will be used.
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -1172,6 +1262,18 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -1478,6 +1580,36 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -2043,6 +2175,36 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
@@ -2271,6 +2433,78 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/resolve-autos
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get workspace sessions
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/sessions \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /workspaces/{workspace}/sessions`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|-------------|-------|--------------|----------|--------------|
|
||||
| `workspace` | path | string(uuid) | true | Workspace ID |
|
||||
| `limit` | query | integer | false | Page limit |
|
||||
| `offset` | query | integer | false | Page offset |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 0,
|
||||
"sessions": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connections": [
|
||||
{
|
||||
"client_hostname": "string",
|
||||
"connected_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"detail": "string",
|
||||
"disconnect_reason": "string",
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"exit_code": 0,
|
||||
"home_derp": {
|
||||
"id": 0,
|
||||
"name": "string"
|
||||
},
|
||||
"ip": "string",
|
||||
"latency_ms": 0,
|
||||
"p2p": true,
|
||||
"short_description": "string",
|
||||
"status": "ongoing",
|
||||
"type": "ssh",
|
||||
"user_agent": "string"
|
||||
}
|
||||
],
|
||||
"ended_at": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"ip": "string",
|
||||
"short_description": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "ongoing"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceSessionsResponse](schemas.md#codersdkworkspacesessionsresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get workspace timings by ID
|
||||
|
||||
### Code samples
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user