mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4451274c7 | ||
|
|
b2e658a2be | ||
|
|
4b2c394181 | ||
|
|
74eae99820 | ||
|
|
da81cb7f50 | ||
|
|
f4e80d37c4 | ||
|
|
de045582e5 | ||
|
|
6a85748b91 | ||
|
|
a9a91905fa | ||
|
|
3459a4db28 | ||
|
|
cba2613e68 | ||
|
|
fe32179dc1 | ||
|
|
1bad86b277 | ||
|
|
3c72444e83 | ||
|
|
386fb12013 | ||
|
|
e3cc74522c | ||
|
|
1537d31f0d |
No files matched your search
File renamed without changes.
@@ -98,6 +98,24 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Docker meta (rootless)
|
||||
id: meta_rootless
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
${{ env.GHCR_IMAGE }}
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-rootless,onlatest=true
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
@@ -123,7 +141,18 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push rootless Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.rootless
|
||||
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm
|
||||
tags: ${{ steps.meta_rootless.outputs.tags }}
|
||||
labels: ${{ steps.meta_rootless.outputs.labels }}
|
||||
|
||||
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
|
||||
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
|
||||
echo '${{ steps.meta_rootless.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
|
||||
@@ -11,6 +11,7 @@
|
||||
# ./build.sh # build + export netbird-dashboard.tar.gz
|
||||
# IMAGE_TAG=v1 ./build.sh # override the image tag
|
||||
# SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build)
|
||||
# ROOTLESS=1 ./build.sh # build the amd64-rootless image variant
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
@@ -19,8 +20,26 @@ cd "$(dirname "$0")"
|
||||
|
||||
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-amd64}"
|
||||
DOCKERFILE="docker/Dockerfile"
|
||||
CONTAINER_PORT=80
|
||||
OUTPUT_SUFFIX=""
|
||||
|
||||
case "${ROOTLESS:-0}" in
|
||||
0) ;;
|
||||
1)
|
||||
DOCKERFILE="docker/Dockerfile.rootless"
|
||||
CONTAINER_PORT=8080
|
||||
OUTPUT_SUFFIX="-rootless"
|
||||
[[ "${IMAGE_TAG}" == *-rootless ]] || IMAGE_TAG="${IMAGE_TAG}-rootless"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: ROOTLESS must be 0 or 1." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
OUTPUT="${OUTPUT:-${IMAGE_NAME}.tar.gz}"
|
||||
OUTPUT="${OUTPUT:-${IMAGE_NAME}${OUTPUT_SUFFIX}.tar.gz}"
|
||||
|
||||
# Build the static export (out/) unless told to reuse an existing one. Next 16
|
||||
# no longer runs ESLint during `next build`; types are checked by the build via
|
||||
@@ -39,17 +58,10 @@ if [[ ! -d out ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The .dockerignore lives under docker/, but Docker reads it from the build
|
||||
# context root. Stage it for the duration of the build so node_modules/.next
|
||||
# don't get shipped into the build context.
|
||||
cleanup() { rm -f .dockerignore; }
|
||||
trap cleanup EXIT
|
||||
cp docker/.dockerignore .dockerignore
|
||||
|
||||
echo "==> Building ${IMAGE} for linux/amd64"
|
||||
docker build \
|
||||
--platform linux/amd64 \
|
||||
-f docker/Dockerfile \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE}" \
|
||||
.
|
||||
|
||||
@@ -60,13 +72,13 @@ echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))"
|
||||
echo
|
||||
echo "Copy and run on the server:"
|
||||
echo " scp ${OUTPUT} user@your-server:/tmp/"
|
||||
echo " docker load < /tmp/${OUTPUT}"
|
||||
echo " docker load < /tmp/${OUTPUT##*/}"
|
||||
echo
|
||||
echo "Run (nginx serves on container port 80). Set the runtime env vars the"
|
||||
echo "Run (nginx serves on container port ${CONTAINER_PORT}). Set the runtime env vars the"
|
||||
echo "dashboard templates at startup — for the Agent Network onboarding test,"
|
||||
echo "flip NETBIRD_AGENT_NETWORK_ONLY=true:"
|
||||
echo " docker run -d --name ${IMAGE_NAME} --restart unless-stopped \\"
|
||||
echo " -p 8080:80 \\"
|
||||
echo " -p 8080:${CONTAINER_PORT} \\"
|
||||
echo " -e NETBIRD_MGMT_API_ENDPOINT=https://your-mgmt:443 \\"
|
||||
echo " -e AUTH_AUTHORITY=https://your-idp/realms/netbird \\"
|
||||
echo " -e AUTH_CLIENT_ID=netbird-dashboard \\"
|
||||
|
||||
+2
-1
@@ -24,5 +24,6 @@
|
||||
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
|
||||
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
|
||||
"hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID",
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS",
|
||||
"announcement": "$NETBIRD_ANNOUNCEMENT"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM alpine:3.24
|
||||
|
||||
RUN apk add --no-cache bash ca-certificates gettext nginx tzdata && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
mkdir -p /tmp/nginx
|
||||
|
||||
STOPSIGNAL SIGINT
|
||||
EXPOSE 8080
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY docker/default.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/rootless.conf /etc/nginx/http.d/rootless.conf
|
||||
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
|
||||
COPY out/ /usr/share/nginx/html/
|
||||
|
||||
RUN sed -i \
|
||||
-e 's/listen 80 default_server;/listen 8080 default_server;/' \
|
||||
-e 's/listen \[::\]:80 default_server;/listen [::]:8080 default_server;/' \
|
||||
/etc/nginx/http.d/default.conf && \
|
||||
sed -i '/^user nginx;$/d' /etc/nginx/nginx.conf && \
|
||||
chmod +x /usr/local/init_react_envs.sh && \
|
||||
chgrp -R 0 /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx && \
|
||||
chmod -R g=u /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx
|
||||
|
||||
USER 10001:0
|
||||
ENTRYPOINT ["/usr/local/init_react_envs.sh"]
|
||||
CMD ["/usr/sbin/nginx", "-e", "/dev/stderr", "-g", "pid /tmp/nginx.pid;"]
|
||||
+47
-11
@@ -1,23 +1,59 @@
|
||||
# NetBird Dashboard
|
||||
NetBird Dashboard is NetBirds Management server UI. It allows users to signin, view setup keys and manage peers. This image is **not ready** for production use.
|
||||
## Tags
|
||||
```latest``` ```vX.X.X``` not available yet.
|
||||
|
||||
```main``` built on every PR being merged to the repository
|
||||
NetBird Dashboard is NetBird's management server UI.
|
||||
|
||||
## Tags
|
||||
|
||||
Each published image tag also has a rootless variant with the `-rootless`
|
||||
suffix. For example, `main` is also published as `main-rootless`.
|
||||
|
||||
## How to use this image
|
||||
HTTP run:
|
||||
|
||||
HTTP:
|
||||
|
||||
```shell
|
||||
docker run -d --rm -p 80:80 wiretrustee/dashboard:main
|
||||
docker run -d --rm -p 80:80 netbirdio/dashboard:main
|
||||
```
|
||||
Using SSL certificate from Let's Encrypt®:
|
||||
|
||||
Using an SSL certificate from Let's Encrypt:
|
||||
|
||||
```shell
|
||||
docker run -d --rm -p 80:80 -p 443:443 \
|
||||
-e LETSENCRYPT_DOMAIN=app.mydomain.com \
|
||||
-e LETSENCRYPT_EMAIL=hello@mydomain.com \
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
> For SSL generation, you need to run this image in a server with proper public IP and a domain name pointing to it.
|
||||
|
||||
For certificate generation, the server needs a public IP and a domain name
|
||||
pointing to it.
|
||||
|
||||
### Rootless and OpenShift
|
||||
|
||||
The rootless image listens on port `8080`, runs as UID `10001` by default, and
|
||||
supports OpenShift's arbitrary UID when the container retains GID `0`:
|
||||
|
||||
```shell
|
||||
docker run -d --rm \
|
||||
--user 12345:0 \
|
||||
-p 8080:8080 \
|
||||
-e AUTH_AUTHORITY=https://idp.example.com \
|
||||
-e AUTH_CLIENT_ID=netbird-dashboard \
|
||||
-e AUTH_AUDIENCE=netbird-dashboard \
|
||||
-e AUTH_SUPPORTED_SCOPES='openid profile email' \
|
||||
-e USE_AUTH0=false \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=https://api.example.com \
|
||||
netbirdio/dashboard:main-rootless
|
||||
```
|
||||
|
||||
The rootless image does not include Certbot or expose HTTPS. Terminate TLS at
|
||||
the OpenShift Route, Ingress, or another reverse proxy.
|
||||
|
||||
## Environment variables
|
||||
* ```NGINX_SSL_PORT``` Changes the port that Nginx listens to. Defaults to ```443```
|
||||
* ```LETSENCRYPT_DOMAIN``` Enables Certbot`s client execution for the specified domain. Defaults to ```none```
|
||||
* ```LETSENCRYPT_EMAIL``` Email used in Certbot`s client execution to register the certificate request. Defaults to ```example@local```
|
||||
|
||||
- `NGINX_SSL_PORT` changes the port that Nginx listens to. Defaults to `443`.
|
||||
- `LETSENCRYPT_DOMAIN` enables Certbot for the specified domain. Defaults to
|
||||
`none`.
|
||||
- `LETSENCRYPT_EMAIL` is used to register the certificate request. Defaults to
|
||||
`example@local`.
|
||||
|
||||
These certificate variables apply only to the standard image.
|
||||
@@ -73,6 +73,8 @@ export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID}
|
||||
export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID}
|
||||
export NETBIRD_HUBSPOT_SURVEY_FORM_ID=${NETBIRD_HUBSPOT_SURVEY_FORM_ID}
|
||||
export NETBIRD_ANALYTICS_EXCLUDED_EMAILS=${NETBIRD_ANALYTICS_EXCLUDED_EMAILS}
|
||||
# A permanent banner for this deployment, shown to every user; empty for none.
|
||||
export NETBIRD_ANNOUNCEMENT=${NETBIRD_ANNOUNCEMENT}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
@@ -163,7 +165,7 @@ sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /
|
||||
}
|
||||
|
||||
# replace ENVs in the config
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS \$\$NETBIRD_ANNOUNCEMENT"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
@@ -173,6 +175,12 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
|
||||
rm "$f".copy
|
||||
done
|
||||
|
||||
# When a command is provided, initialization happens before the server starts.
|
||||
# The rootless image uses this path so nginx never needs to be reloaded.
|
||||
if (( $# > 0 )); then
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Reload nginx so the patched CSP header takes effect.
|
||||
# supervisord starts nginx (priority 100) before this script (priority 201) and
|
||||
# never reloads it, so without this nginx keeps serving the static default.conf
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# nginx includes this file inside the http context.
|
||||
# All runtime state stays in a directory writable by the arbitrary UID used by OpenShift.
|
||||
client_body_temp_path /tmp/nginx/client_temp;
|
||||
proxy_temp_path /tmp/nginx/proxy_temp;
|
||||
fastcgi_temp_path /tmp/nginx/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/nginx/uwsgi_temp;
|
||||
scgi_temp_path /tmp/nginx/scgi_temp;
|
||||
@@ -1,6 +1,7 @@
|
||||
# Ignore zitadel environment
|
||||
.env
|
||||
Caddyfile
|
||||
agentgateway-stub.Caddyfile
|
||||
management.json
|
||||
turnserver.conf
|
||||
zitadel.env
|
||||
|
||||
@@ -19,7 +19,7 @@ check_docker_compose() {
|
||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||
|
||||
$DOCKER_COMPOSE_COMMAND down --volumes
|
||||
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
|
||||
rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
|
||||
rm -rf proxy-certs proxy-certs-no-ports
|
||||
rm -f ../../.test-config.json ../playwright.env.json
|
||||
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
|
||||
@@ -532,7 +532,7 @@ initEnvironment() {
|
||||
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
|
||||
echo "You can use the following commands:"
|
||||
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
|
||||
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
|
||||
echo " rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
|
||||
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
|
||||
exit 1
|
||||
fi
|
||||
@@ -540,6 +540,7 @@ initEnvironment() {
|
||||
echo Rendering initial files...
|
||||
renderDockerCompose > docker-compose.yml
|
||||
renderCaddyfile > Caddyfile
|
||||
renderAgentGatewayStub > agentgateway-stub.Caddyfile
|
||||
renderZitadelEnv > zitadel.env
|
||||
echo "" > turnserver.conf
|
||||
echo "" > management.json
|
||||
@@ -612,6 +613,30 @@ initEnvironment() {
|
||||
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
|
||||
}
|
||||
|
||||
# Saving an Agent Network provider makes management call the upstream and
|
||||
# refuses the save unless it answers a model listing, so a provider spec needs
|
||||
# a reachable OpenAI-compatible endpoint. agentgateway is self-hosted and has
|
||||
# no public one; this stub stands in for it, inside the compose network where
|
||||
# management can reach it and without depending on a vendor being up.
|
||||
renderAgentGatewayStub() {
|
||||
cat <<'EOF'
|
||||
{
|
||||
admin off
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:8088 {
|
||||
handle /v1/models {
|
||||
header Content-Type application/json
|
||||
respond `{"object":"list","data":[{"id":"agentgateway-e2e-model","object":"model","owned_by":"netbird-e2e"}]}` 200
|
||||
}
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
renderCaddyfile() {
|
||||
cat <<EOF
|
||||
{
|
||||
@@ -818,6 +843,15 @@ services:
|
||||
volumes:
|
||||
- netbird_caddy_data:/data
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
# Stand-in upstream for the Agent Network provider specs: answers the model
|
||||
# listing management probes before it accepts a provider save. Reachable
|
||||
# only from inside the compose network, at http://agentgateway-stub:8088.
|
||||
agentgateway-stub:
|
||||
image: caddy
|
||||
restart: unless-stopped
|
||||
networks: [ netbird ]
|
||||
volumes:
|
||||
- ./agentgateway-stub.Caddyfile:/etc/caddy/Caddyfile
|
||||
# Management
|
||||
management:
|
||||
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
|
||||
@@ -892,6 +926,16 @@ services:
|
||||
# GeoLite2 download removes a startup stall of up to 2 minutes
|
||||
# when pkgs.netbird.io is slow.
|
||||
- NB_PROXY_DISABLE_GEOLOCATION=true
|
||||
# Declare the private capability so this cluster can host the agent
|
||||
# network endpoint. That endpoint is a private service — reachable only
|
||||
# from connected peers — and management refuses to bootstrap it onto a
|
||||
# cluster without a private-capable proxy; the connect-provider wizard
|
||||
# reads the same flag (supports_private) to pick its cluster. Without
|
||||
# it no cluster qualifies and the wizard stops at the provider tab,
|
||||
# which strands every spec that walks through it. The no-ports proxy
|
||||
# below stays centralised, so the pick has to skip past a cluster that
|
||||
# does not qualify.
|
||||
- NB_PROXY_PRIVATE=true
|
||||
volumes:
|
||||
- ./proxy-certs:/certs:ro
|
||||
command: [
|
||||
|
||||
+145
-13
@@ -46,8 +46,7 @@ async function getApiContext(
|
||||
]);
|
||||
|
||||
const request = response.request();
|
||||
const authHeader =
|
||||
(await request.allHeaders())["authorization"] || "";
|
||||
const authHeader = (await request.allHeaders())["authorization"] || "";
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const url = new URL(request.url());
|
||||
const origin = `${url.protocol}//${url.host}`;
|
||||
@@ -76,15 +75,35 @@ async function apiDelete(page: Page, path: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function apiPost<T>(page: Page, path: string, data: unknown): Promise<T> {
|
||||
async function apiPost<T>(page: Page, path: string, body: unknown): Promise<T> {
|
||||
const { token, origin } = await getApiContext(page);
|
||||
const resp = await page.request.post(`${origin}/api${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data,
|
||||
data: body,
|
||||
});
|
||||
if (!resp.ok()) {
|
||||
throw new Error(
|
||||
`POST ${path} returned ${resp.status()}: ${await resp.text()}`,
|
||||
);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiPut<T>(page: Page, path: string, body: unknown): Promise<T> {
|
||||
const { token, origin } = await getApiContext(page);
|
||||
const resp = await page.request.put(`${origin}/api${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: body,
|
||||
});
|
||||
if (!resp.ok()) {
|
||||
throw new Error(
|
||||
`PUT ${path} returned ${resp.status()}: ${await resp.text()}`,
|
||||
);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/** List all groups. */
|
||||
export async function listGroups(page: Page): Promise<Group[]> {
|
||||
return apiGet<Group[]>(page, "/groups");
|
||||
}
|
||||
@@ -108,10 +127,12 @@ export async function deletePeersByPrefix(page: Page, prefix: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a group by name. */
|
||||
export async function createGroup(page: Page, name: string): Promise<Group> {
|
||||
return apiPost<Group>(page, "/groups", { name, peers: [] });
|
||||
}
|
||||
|
||||
/** Delete a group by ID. */
|
||||
export async function deleteGroup(page: Page, groupId: string) {
|
||||
await apiDelete(page, `/groups/${groupId}`);
|
||||
}
|
||||
@@ -259,7 +280,10 @@ export async function deleteRouteById(page: Page, routeId: string) {
|
||||
await apiDelete(page, `/routes/${routeId}`);
|
||||
}
|
||||
|
||||
export async function deleteRoutesByNetworkIdPrefix(page: Page, prefix: string) {
|
||||
export async function deleteRoutesByNetworkIdPrefix(
|
||||
page: Page,
|
||||
prefix: string,
|
||||
) {
|
||||
const routes = await listRoutes(page);
|
||||
const toDelete = routes.filter((r) => r.network_id.startsWith(prefix));
|
||||
for (const r of toDelete) {
|
||||
@@ -338,8 +362,13 @@ type NotificationChannel = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export async function listNotificationChannels(page: Page): Promise<NotificationChannel[]> {
|
||||
return apiGet<NotificationChannel[]>(page, "/integrations/notifications/channels");
|
||||
export async function listNotificationChannels(
|
||||
page: Page,
|
||||
): Promise<NotificationChannel[]> {
|
||||
return apiGet<NotificationChannel[]>(
|
||||
page,
|
||||
"/integrations/notifications/channels",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteNotificationChannel(page: Page, channelId: string) {
|
||||
@@ -353,7 +382,10 @@ export async function deleteAllNotificationChannels(page: Page) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNotificationChannelsByType(page: Page, type: string) {
|
||||
export async function deleteNotificationChannelsByType(
|
||||
page: Page,
|
||||
type: string,
|
||||
) {
|
||||
const channels = await listNotificationChannels(page);
|
||||
const toDelete = channels.filter((c) => c.type === type);
|
||||
for (const c of toDelete) {
|
||||
@@ -368,7 +400,9 @@ type NameserverGroup = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export async function listNameserverGroups(page: Page): Promise<NameserverGroup[]> {
|
||||
export async function listNameserverGroups(
|
||||
page: Page,
|
||||
): Promise<NameserverGroup[]> {
|
||||
return apiGet<NameserverGroup[]>(page, "/dns/nameservers");
|
||||
}
|
||||
|
||||
@@ -376,7 +410,10 @@ export async function deleteNameserverGroupById(page: Page, id: string) {
|
||||
await apiDelete(page, `/dns/nameservers/${id}`);
|
||||
}
|
||||
|
||||
export async function deleteNameserverGroupsByPrefix(page: Page, prefix: string) {
|
||||
export async function deleteNameserverGroupsByPrefix(
|
||||
page: Page,
|
||||
prefix: string,
|
||||
) {
|
||||
const groups = await listNameserverGroups(page);
|
||||
const toDelete = groups.filter((g) => g.name.startsWith(prefix));
|
||||
for (const g of toDelete) {
|
||||
@@ -391,11 +428,16 @@ type ReverseProxyService = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export async function listReverseProxyServices(page: Page): Promise<ReverseProxyService[]> {
|
||||
export async function listReverseProxyServices(
|
||||
page: Page,
|
||||
): Promise<ReverseProxyService[]> {
|
||||
return apiGet<ReverseProxyService[]>(page, "/reverse-proxies/services");
|
||||
}
|
||||
|
||||
export async function deleteReverseProxyServiceById(page: Page, serviceId: string) {
|
||||
export async function deleteReverseProxyServiceById(
|
||||
page: Page,
|
||||
serviceId: string,
|
||||
) {
|
||||
await apiDelete(page, `/reverse-proxies/services/${serviceId}`);
|
||||
}
|
||||
|
||||
@@ -449,7 +491,13 @@ export async function waitForProxyClustersOnline(
|
||||
throw new Error(
|
||||
`Proxy clusters not online after ${timeoutMs}ms. Expected ${addresses.join(
|
||||
", ",
|
||||
)}; got ${JSON.stringify(last.map((c) => ({ a: c.address, online: c.online, n: c.connected_proxies })))}`,
|
||||
)}; got ${JSON.stringify(
|
||||
last.map((c) => ({
|
||||
a: c.address,
|
||||
online: c.online,
|
||||
n: c.connected_proxies,
|
||||
})),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -461,6 +509,8 @@ type User = {
|
||||
name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
auto_groups: string[];
|
||||
is_blocked: boolean;
|
||||
is_current: boolean;
|
||||
};
|
||||
|
||||
@@ -538,3 +588,85 @@ export async function supportsAgentNetworkSettingsBootstrap(
|
||||
!!settings && typeof settings === "object" && "proxy_address" in settings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the management build under test serves the caller-scoped
|
||||
* GET /agent-network/agent-config answer that backs the Connect Agent page.
|
||||
* The endpoint answers 200 for every authenticated caller (an unconfigured
|
||||
* caller gets configured=false, never an error), so any non-OK status means
|
||||
* the build predates it.
|
||||
*/
|
||||
export async function supportsAgentNetworkAgentConfig(
|
||||
page: Page,
|
||||
): Promise<boolean> {
|
||||
const { token, origin } = await getApiContext(page);
|
||||
const resp = await page.request.get(
|
||||
`${origin}/api/agent-network/agent-config`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
return resp.ok();
|
||||
}
|
||||
|
||||
type AgentNetworkPolicy = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** List Agent Network policies. */
|
||||
export async function listAgentNetworkPolicies(
|
||||
page: Page,
|
||||
): Promise<AgentNetworkPolicy[]> {
|
||||
return apiGet<AgentNetworkPolicy[]>(page, "/agent-network/policies");
|
||||
}
|
||||
|
||||
/** Create an Agent Network policy. */
|
||||
export async function createAgentNetworkPolicy(
|
||||
page: Page,
|
||||
body: {
|
||||
name: string;
|
||||
source_groups: string[];
|
||||
destination_provider_ids: string[];
|
||||
enabled?: boolean;
|
||||
},
|
||||
): Promise<AgentNetworkPolicy> {
|
||||
return apiPost<AgentNetworkPolicy>(page, "/agent-network/policies", {
|
||||
enabled: true,
|
||||
...body,
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete all Agent Network policies whose name starts with the prefix. */
|
||||
export async function deleteAgentNetworkPoliciesByPrefix(
|
||||
page: Page,
|
||||
prefix: string,
|
||||
) {
|
||||
const policies = await listAgentNetworkPolicies(page);
|
||||
for (const p of policies) {
|
||||
if (p.name.startsWith(prefix)) {
|
||||
await apiDelete(page, `/agent-network/policies/${p.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The user the captured token belongs to. */
|
||||
export async function getCurrentUser(page: Page): Promise<User> {
|
||||
const users = await apiGet<User[]>(page, "/users");
|
||||
const current = users.find((u) => u.is_current);
|
||||
if (!current) {
|
||||
throw new Error("no is_current user in the /users answer");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Replace a user's auto-groups, keeping role and blocked state. */
|
||||
export async function updateUserAutoGroups(
|
||||
page: Page,
|
||||
user: User,
|
||||
autoGroups: string[],
|
||||
): Promise<void> {
|
||||
await apiPut(page, `/users/${user.id}`, {
|
||||
role: user.role,
|
||||
auto_groups: autoGroups,
|
||||
is_blocked: !!user.is_blocked,
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -33,7 +33,7 @@ export async function loginToApp(
|
||||
// Use locators that match either outcome — Playwright auto-waits.
|
||||
const appReady = page.getByTestId("left-navigation-item").first();
|
||||
const setupModal = page.getByTestId("setup-netbird-modal");
|
||||
const approvalPending = page.getByText("User Approval Pending");
|
||||
const approvalPending = page.getByTestId("pending-approval");
|
||||
const onboarding = page.getByText("Add new device to your network");
|
||||
const selectAccount = page.getByText("Select account");
|
||||
const loginInput = page.locator("input[id=loginName]");
|
||||
|
||||
@@ -24,6 +24,31 @@ export async function openControlCenter(page: Page, tab?: FlowView) {
|
||||
await dismissBlockingOverlays(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the groups view already showing `groupName`.
|
||||
*
|
||||
* The view picks its own group on first render (getFirstGroup: the highest
|
||||
* peers_count that is a policy source), and that ranking runs over every group
|
||||
* in the account — including ones a spec on the other worker seeded moments
|
||||
* ago. Deleting the spec's own fixtures first is not enough to pin it, so
|
||||
* select the group by name and let the view rebuild around it.
|
||||
*/
|
||||
export async function openGroupView(page: Page, groupName: string) {
|
||||
await openControlCenter(page, "groups");
|
||||
const selector = canvasNode(page, "select-group-node");
|
||||
await expect(selector).toBeVisible();
|
||||
// Unforced on purpose: the opening fitView animates the canvas, and the
|
||||
// actionability check is what waits for the node to stop moving.
|
||||
await selector.click();
|
||||
await page.getByTestId("select-dropdown-search").fill(groupName);
|
||||
await page
|
||||
.locator('[role="option"]')
|
||||
.filter({ hasText: groupName })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
await expect(selector).toContainText(groupName);
|
||||
}
|
||||
|
||||
export async function switchFlowView(page: Page, view: FlowView) {
|
||||
await dismissBlockingOverlays(page);
|
||||
await page.getByTestId(`cc-flow-${view}`).click({ force: true });
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Agent Network agentgateway provider spec.
|
||||
*
|
||||
* Exercises the catalog-driven provider flow against a management build that
|
||||
* includes the agentgateway catalog entry. Older builds skip the test.
|
||||
*/
|
||||
import {
|
||||
deleteAgentNetworkProvidersByPrefix,
|
||||
listAgentNetworkCatalog,
|
||||
supportsAgentNetworkSettingsBootstrap,
|
||||
} from "../helpers/api";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { expect, test } from "../helpers/fixtures";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
const AGENTGATEWAY_CATALOG_ID = "agentgateway";
|
||||
const PROVIDER_PREFIX = "e2e-agentgateway-";
|
||||
|
||||
test.describe
|
||||
.serial("Agent Network agentgateway provider @agent-network", () => {
|
||||
test("connect agentgateway with its trusted identity mapping", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await page.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
|
||||
const catalog = await listAgentNetworkCatalog(page);
|
||||
test.skip(
|
||||
!catalog.some((entry) => entry.id === AGENTGATEWAY_CATALOG_ID),
|
||||
`management catalog has no ${AGENTGATEWAY_CATALOG_ID} entry`,
|
||||
);
|
||||
test.skip(
|
||||
!(await supportsAgentNetworkSettingsBootstrap(page)),
|
||||
"management build does not support Agent Network settings bootstrap",
|
||||
);
|
||||
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
try {
|
||||
await navigateTo(page, "/agent-network/providers");
|
||||
|
||||
await page
|
||||
.getByTestId("connect-agent-network-provider")
|
||||
.first()
|
||||
.click({ force: true });
|
||||
await page
|
||||
.getByTestId("agent-network-provider-type")
|
||||
.click({ force: true });
|
||||
await page
|
||||
.getByTestId("select-dropdown-search")
|
||||
.fill(AGENTGATEWAY_CATALOG_ID);
|
||||
await page
|
||||
.getByTestId("agent-network-provider-option-agentgateway")
|
||||
.click({ force: true });
|
||||
|
||||
// Saving a provider makes management call the upstream, and it refuses
|
||||
// the save with a 422 unless the upstream answers a model listing — so a
|
||||
// made-up host cannot be used here. agentgateway is self-hosted and has
|
||||
// no public endpoint to point at, so create-test-env.sh runs a stub that
|
||||
// answers that probe inside the compose network.
|
||||
const upstreamURL = "http://agentgateway-stub:8088";
|
||||
await page
|
||||
.getByTestId("agent-network-provider-upstream-url")
|
||||
.fill(upstreamURL);
|
||||
await page
|
||||
.getByTestId("agent-network-provider-api-key")
|
||||
.fill("e2e-agentgateway-virtual-key");
|
||||
|
||||
const providerName = generateRandomName(PROVIDER_PREFIX);
|
||||
await page.getByTestId("agent-network-provider-name").fill(providerName);
|
||||
|
||||
await page
|
||||
.getByTestId("agent-network-provider-models-tab")
|
||||
.click({ force: true });
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-models-help"),
|
||||
).toContainText("Empty = all catalog models");
|
||||
await page
|
||||
.getByTestId("agent-network-provider-continue")
|
||||
.click({ force: true });
|
||||
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-mappings-tab"),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-user-mapping"),
|
||||
).toContainText("x-netbird-user-id");
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-groups-mapping"),
|
||||
).toContainText("x-netbird-groups");
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-groups-guidance"),
|
||||
).toContainText("must not be used as an agentgateway authorization claim");
|
||||
|
||||
const createResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/agent-network/providers") &&
|
||||
response.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("agent-network-provider-submit").click({
|
||||
force: true,
|
||||
});
|
||||
const created = await createResponse;
|
||||
expect([200, 201]).toContain(created.status());
|
||||
|
||||
const body = created.request().postDataJSON();
|
||||
expect(body).toMatchObject({
|
||||
provider_id: AGENTGATEWAY_CATALOG_ID,
|
||||
name: providerName,
|
||||
upstream_url: upstreamURL,
|
||||
models: [],
|
||||
metadata_disabled: false,
|
||||
});
|
||||
expect(body).not.toHaveProperty("identity_header_user_id");
|
||||
expect(body).not.toHaveProperty("identity_header_groups");
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-modal"),
|
||||
).toBeHidden();
|
||||
|
||||
await expect(page.getByTestId(providerName)).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`provider-models-${providerName}`),
|
||||
).toHaveText("All Models");
|
||||
|
||||
await page.getByTestId(providerName).click({ force: true });
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-modal"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-upstream-url"),
|
||||
).toHaveValue(upstreamURL);
|
||||
await page
|
||||
.getByTestId("agent-network-provider-mappings-tab")
|
||||
.click({ force: true });
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-user-mapping"),
|
||||
).toContainText("x-netbird-user-id");
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-groups-mapping"),
|
||||
).toContainText("x-netbird-groups");
|
||||
await page
|
||||
.getByTestId("agent-network-provider-modal")
|
||||
.getByTestId("modal-close")
|
||||
.click({ force: true });
|
||||
} finally {
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Agent Network bootstrap-cluster spec.
|
||||
*
|
||||
* The account's agent network endpoint is assigned once, from the proxy
|
||||
* cluster the connect-provider wizard pins it to, and it is immutable after
|
||||
* that. Not every live cluster can host it: the endpoint is a private service,
|
||||
* reachable only from connected peers and authorised by their tunnel identity,
|
||||
* which only a cluster with private capabilities can serve. Management
|
||||
* reports that per cluster as supports_private and refuses a
|
||||
* bootstrap onto a cluster without it.
|
||||
*
|
||||
* This spec pins the wizard's half of that contract: it picks a
|
||||
* private-capable cluster even when a centralised one is listed first, and it
|
||||
* blocks setup — with a message naming what is missing — when no listed
|
||||
* cluster qualifies. Both the settings and domains routes are mocked, so the
|
||||
* assertions do not depend on which clusters the test backend happens to have
|
||||
* connected, and no provider is created against it.
|
||||
*/
|
||||
import { type Browser, expect, type Page, test } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
|
||||
// bug this spec guards against.
|
||||
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
|
||||
// Listed second, and the only valid pick.
|
||||
const PRIVATE_CLUSTER = "private.proxy.netbird.io";
|
||||
|
||||
const UNBOOTSTRAPPED_DEFAULTS = {
|
||||
endpoint: "",
|
||||
proxy_address: "",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
};
|
||||
|
||||
const BOOTSTRAPPED_SETTINGS = {
|
||||
...UNBOOTSTRAPPED_DEFAULTS,
|
||||
endpoint: `violet.${PRIVATE_CLUSTER}`,
|
||||
proxy_address: PRIVATE_CLUSTER,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
|
||||
function freeDomain(name: string, supportsPrivate: boolean) {
|
||||
return {
|
||||
id: name,
|
||||
domain: name,
|
||||
validated: true,
|
||||
type: "free",
|
||||
supports_private: supportsPrivate,
|
||||
supports_custom_ports: true,
|
||||
require_subdomain: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function json(route: import("@playwright/test").Route, body: unknown) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the providers page for an account that has not been bootstrapped,
|
||||
* with the given clusters on the domains endpoint. Returns the page plus a
|
||||
* promise-friendly getter for the bootstrap POST body, which is captured and
|
||||
* answered locally so the real backend is never mutated.
|
||||
*/
|
||||
async function openWizard(
|
||||
browser: Browser,
|
||||
domains: ReturnType<typeof freeDomain>[],
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
try {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.route("**/api/agent-network/settings", (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
|
||||
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
|
||||
return route.continue();
|
||||
});
|
||||
await page.route("**/api/reverse-proxies/domains", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? json(route, domains)
|
||||
: route.continue(),
|
||||
);
|
||||
// Keep the create hermetic: the wizard only reaches this once the
|
||||
// bootstrap it does first has succeeded.
|
||||
await page.route("**/api/agent-network/providers", (route) =>
|
||||
route.request().method() === "POST"
|
||||
? json(route, { id: "prov-e2e", name: "e2e-provider" })
|
||||
: route.continue(),
|
||||
);
|
||||
|
||||
await loginToApp(page, "owner");
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
return { page, close: () => context.close() };
|
||||
} catch (e) {
|
||||
await context.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fills the provider tab with the minimum a create needs. */
|
||||
async function fillProviderTab(page: Page) {
|
||||
await page
|
||||
.getByPlaceholder("https://api.openai.com")
|
||||
.fill("https://api.openai.com");
|
||||
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
|
||||
}
|
||||
|
||||
test.describe("Agent Network bootstrap cluster @agent-network", () => {
|
||||
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
freeDomain(PRIVATE_CLUSTER, true),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
// The unusable cluster is listed, but it is not a blocker: a usable one
|
||||
// exists, so the wizard proceeds without the warning.
|
||||
await expect(
|
||||
page.getByTestId("agent-network-no-cluster-callout"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await fillProviderTab(page);
|
||||
|
||||
const bootstrap = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/agent-network/settings") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Continue" })
|
||||
.click({ force: true });
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.last()
|
||||
.click({ force: true });
|
||||
|
||||
const payload = (await bootstrap).request().postDataJSON();
|
||||
expect(payload.proxy_address).toBe(PRIVATE_CLUSTER);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks setup when no listed cluster has private capabilities", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
const callout = page.getByTestId("agent-network-no-cluster-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 10_000 });
|
||||
await expect(callout).toContainText("private capabilities");
|
||||
// The fix lives in the agent network's own Clusters section, not the
|
||||
// generic Reverse Proxy page, so that is where the callout sends users.
|
||||
await expect(callout.getByRole("link")).toHaveAttribute(
|
||||
"href",
|
||||
"/agent-network/configuration?tab=clusters",
|
||||
);
|
||||
|
||||
// With nothing to pin to there is no bootstrap to make, so the wizard
|
||||
// cannot advance — rather than sending a request management refuses.
|
||||
await fillProviderTab(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue" }),
|
||||
).toBeDisabled();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,9 @@
|
||||
*
|
||||
* Walks the Kimi provider lifecycle end to end against the real backend:
|
||||
* pick kimi_api from the catalog (prefilled host, catalog models with
|
||||
* pricing), connect it, then verify the Kimi-gated config surfaces in the
|
||||
* "Configure Your Agent" modal (Kimi CLI tab, Kimi backend option in the
|
||||
* Claude Code tab) that only render when a Kimi provider is connected.
|
||||
* pricing), connect it, then verify the Kimi-gated config surfaces on the
|
||||
* Connect Agent page (Kimi CLI tab, Kimi backend option in the Claude Code
|
||||
* tab) that only render when a Kimi provider is connected.
|
||||
*
|
||||
* The kimi_api catalog entry ships with newer management builds. When the
|
||||
* backend under test predates it, the whole suite skips instead of failing —
|
||||
@@ -19,20 +19,57 @@
|
||||
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
|
||||
* set via addInitScript on a dedicated context below.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { type Browser, expect, type Page,test } from "@playwright/test";
|
||||
import {
|
||||
createAgentNetworkPolicy,
|
||||
createGroup,
|
||||
deleteAgentNetworkPoliciesByPrefix,
|
||||
deleteAgentNetworkProvidersByPrefix,
|
||||
deleteGroup,
|
||||
getCurrentUser,
|
||||
listAgentNetworkCatalog,
|
||||
listGroups,
|
||||
supportsAgentNetworkAgentConfig,
|
||||
supportsAgentNetworkSettingsBootstrap,
|
||||
updateUserAutoGroups,
|
||||
} from "../helpers/api";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import {
|
||||
deleteAgentNetworkProvidersByPrefix,
|
||||
listAgentNetworkCatalog,
|
||||
supportsAgentNetworkSettingsBootstrap,
|
||||
} from "../helpers/api";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
const KIMI_CATALOG_ID = "kimi_api";
|
||||
const KIMI_CATALOG_NAME = "Kimi (Moonshot AI) API";
|
||||
const PROVIDER_PREFIX = "e2e-kimi-";
|
||||
|
||||
/**
|
||||
* Remove every fixture this spec creates: the policy granting the caller the
|
||||
* provider, the group carrying that grant (detached from the caller first —
|
||||
* a group referenced by a user's auto-groups refuses deletion), and the
|
||||
* provider itself. Runs before the test too, so leftovers from an interrupted
|
||||
* run never poison the next one.
|
||||
*/
|
||||
async function cleanupKimiFixtures(page: Page) {
|
||||
await deleteAgentNetworkPoliciesByPrefix(page, PROVIDER_PREFIX);
|
||||
const fixtureGroups = (await listGroups(page)).filter((g) =>
|
||||
g.name.startsWith(PROVIDER_PREFIX),
|
||||
);
|
||||
if (fixtureGroups.length > 0) {
|
||||
const fixtureGroupIds = new Set(fixtureGroups.map((g) => g.id));
|
||||
const caller = await getCurrentUser(page);
|
||||
if ((caller.auto_groups ?? []).some((id) => fixtureGroupIds.has(id))) {
|
||||
await updateUserAutoGroups(
|
||||
page,
|
||||
caller,
|
||||
(caller.auto_groups ?? []).filter((id) => !fixtureGroupIds.has(id)),
|
||||
);
|
||||
}
|
||||
for (const g of fixtureGroups) {
|
||||
await deleteGroup(page, g.id);
|
||||
}
|
||||
}
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
}
|
||||
|
||||
async function newAgentNetworkPage(browser: Browser): Promise<{
|
||||
page: Page;
|
||||
close: () => Promise<void>;
|
||||
@@ -71,8 +108,17 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
"management build has no POST /agent-network/settings, so the " +
|
||||
"wizard cannot bootstrap the account before the first create",
|
||||
);
|
||||
// The Kimi-gated config surfaces live on the Connect Agent page, whose
|
||||
// caller-scoped GET /agent-network/agent-config answer ships with
|
||||
// newer management builds. The suite starts running once the backend
|
||||
// under test carries it.
|
||||
test.skip(
|
||||
!(await supportsAgentNetworkAgentConfig(page)),
|
||||
"management build has no GET /agent-network/agent-config, so the " +
|
||||
"Connect Agent page cannot render the agent config",
|
||||
);
|
||||
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
await cleanupKimiFixtures(page);
|
||||
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
@@ -153,17 +199,36 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
.click({ force: true });
|
||||
const created = await Promise.race([createResponse, bootstrapRejected]);
|
||||
expect([200, 201]).toContain(created.status());
|
||||
const createdProvider = (await created.json()) as { id: string };
|
||||
|
||||
// Row lands in the providers table.
|
||||
await expect(page.getByText(providerName).first()).toBeVisible();
|
||||
|
||||
// ---- Kimi-gated agent config surfaces ----
|
||||
await page
|
||||
.getByRole("button", { name: "Agent Config" })
|
||||
.click({ force: true });
|
||||
// The agent config lives inline on the Connect Agent page — the
|
||||
// providers page keeps only the endpoint URL and Copy. That page's
|
||||
// answer is caller-scoped: it offers only providers the caller's own
|
||||
// policies authorize, so grant the caller the new provider through a
|
||||
// dedicated group + policy (removed again by cleanupKimiFixtures).
|
||||
const caller = await getCurrentUser(page);
|
||||
const grantGroup = await createGroup(page, `${PROVIDER_PREFIX}grant`);
|
||||
await updateUserAutoGroups(page, caller, [
|
||||
...(caller.auto_groups ?? []),
|
||||
grantGroup.id,
|
||||
]);
|
||||
await createAgentNetworkPolicy(page, {
|
||||
name: `${PROVIDER_PREFIX}policy`,
|
||||
source_groups: [grantGroup.id],
|
||||
destination_provider_ids: [createdProvider.id],
|
||||
});
|
||||
|
||||
// Kimi CLI tab only renders when a kimi_api provider is connected.
|
||||
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
|
||||
await page.goto("/agent-network/connect");
|
||||
|
||||
// Kimi CLI tab only renders when a kimi_api provider is connected. The
|
||||
// longer timeout covers the page's caller-scoped agent-config fetch.
|
||||
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Claude Code tab's backend dropdown offers (and, with Kimi as the only
|
||||
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
|
||||
@@ -192,10 +257,8 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
await expect(page.getByText('wire_api = "responses"')).toBeVisible();
|
||||
await expect(page.getByText('wire_api = "chat"')).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// ---- cleanup ----
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
await cleanupKimiFixtures(page);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Agent Network provider save: what the operator sees when the backend refuses.
|
||||
*
|
||||
* Saving a provider checks its upstream URL and credential against the vendor
|
||||
* before storing anything, so a 422 naming the field to correct is an ordinary
|
||||
* outcome of the form rather than a server fault. Four things have to hold,
|
||||
* and each one has been wrong at some point:
|
||||
*
|
||||
* - while the check runs the submit says so and refuses a second click. The
|
||||
* vendor call is the slow part and an unreachable upstream drags it out to
|
||||
* a timeout, which looked exactly like a button that had not registered
|
||||
* the first click.
|
||||
* - exactly ONE toast, the shared "Request failed with status code N", which
|
||||
* quotes the API's own sentence. A second toast from the save path said the
|
||||
* same thing under a vaguer title.
|
||||
* - the toast is styled as a failure. notify() renders green with a check
|
||||
* mark unless told otherwise, so a refusal announced itself as a success.
|
||||
* - the modal stays open with the typed values intact. The API never returns
|
||||
* an API key, so closing the form loses it with nowhere to retype it.
|
||||
*
|
||||
* The refusal is mocked rather than provoked: the backend check ships with a
|
||||
* management build these tests do not pin, and what is under test here is the
|
||||
* dashboard's handling of the response, not the vendor call that produces it.
|
||||
*
|
||||
* Like the other agent-network specs, this one builds its own context rather
|
||||
* than taking the shared dashboardAsOwner fixture: the Agent Network menu is
|
||||
* deployment-gated behind a localStorage override that has to be in place
|
||||
* before the first navigation, which addInitScript on an own context is the
|
||||
* way to do. The route interception below is a second reason — the shared page
|
||||
* is worker-scoped, and a 422 left on it would follow every later test.
|
||||
*/
|
||||
import { type Browser, expect, type Page, test } from "@playwright/test";
|
||||
import { loginToApp, navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
const PROVIDERS_ENDPOINT = /\/api\/agent-network\/providers(\?|$)/;
|
||||
const PROVIDER_PREFIX = "e2e-refused-";
|
||||
const TITLE_TESTID = "notification-title";
|
||||
|
||||
// The message a refused save carries: the backend names which of the two
|
||||
// fields is at fault, without a status code and without echoing the URL.
|
||||
const REFUSAL = "the upstream url could not be reached: no such host";
|
||||
|
||||
// Matched case-insensitively on purpose. The backend lowercases its messages
|
||||
// (WriteError does, and the copy is written for it) while the toast uppercases
|
||||
// the first character before rendering. Asserting either spelling would pin
|
||||
// the test to that transform rather than to the sentence the operator reads.
|
||||
const REFUSAL_TEXT = new RegExp(
|
||||
REFUSAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
"i",
|
||||
);
|
||||
|
||||
async function newAgentNetworkPage(browser: Browser): Promise<{
|
||||
page: Page;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "owner");
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
// How long the mocked create takes to answer. The real check is a vendor round
|
||||
// trip, so a save is never instant; holding the response gives the in-flight
|
||||
// assertions a window to run in without racing the toast.
|
||||
const REFUSAL_DELAY_MS = 1500;
|
||||
|
||||
// refuseProviderCreate answers the create with the 422 the credential check
|
||||
// produces. Only POST is intercepted: the page still lists providers, and the
|
||||
// settings bootstrap that may precede the create is left alone.
|
||||
async function refuseProviderCreate(page: Page) {
|
||||
await page.route(PROVIDERS_ENDPOINT, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.continue();
|
||||
const origin = route.request().headers()["origin"] || "*";
|
||||
await new Promise((resolve) => setTimeout(resolve, REFUSAL_DELAY_MS));
|
||||
await route.fulfill({
|
||||
status: 422,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"access-control-allow-origin": origin,
|
||||
},
|
||||
body: JSON.stringify({ code: 422, message: REFUSAL }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe
|
||||
.serial("Agent Network refused provider save @agent-network", () => {
|
||||
test("reports the refusal once and keeps the form open", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await newAgentNetworkPage(browser);
|
||||
try {
|
||||
await refuseProviderCreate(page);
|
||||
|
||||
// navigateTo rather than goto: it dismisses the setup modal that greets a
|
||||
// fresh account and clears the scroll lock it leaves behind, either of
|
||||
// which swallows the clicks below.
|
||||
await navigateTo(page, "/agent-network/providers");
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
const providerName = generateRandomName(PROVIDER_PREFIX);
|
||||
await page
|
||||
.getByTestId("agent-network-provider-name")
|
||||
.fill(providerName);
|
||||
await page
|
||||
.getByTestId("agent-network-provider-api-key")
|
||||
.fill("sk-e2e-refused-key");
|
||||
|
||||
// The submit lives on the Models tab — the Provider tab's primary button
|
||||
// only advances to it.
|
||||
await page.getByRole("tab", { name: "Models" }).click({ force: true });
|
||||
const submit = page.getByTestId("agent-network-provider-submit");
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
const refusal = page.waitForResponse(
|
||||
(resp) =>
|
||||
PROVIDERS_ENDPOINT.test(resp.url()) &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await submit.click({ force: true });
|
||||
|
||||
// ---- the wait is visible while it lasts ----
|
||||
// A second create is not idempotent, so the button has to say it is
|
||||
// working and stop taking clicks rather than sit there looking untouched.
|
||||
// Asserted before the response is awaited: the mock holds it open for
|
||||
// exactly this window.
|
||||
await expect(submit).toContainText("Connecting provider");
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
expect((await refusal).status()).toBe(422);
|
||||
|
||||
// ---- the toast says what the API said ----
|
||||
const title = page.getByTestId(TITLE_TESTID).first();
|
||||
await expect(title).toContainText("Request failed with status code 422");
|
||||
await expect(
|
||||
page.locator("[data-toast-notification]").first(),
|
||||
).toContainText(REFUSAL_TEXT);
|
||||
|
||||
// ---- and only that toast ----
|
||||
// The save path used to add its own on top, so the count is the
|
||||
// assertion rather than the presence of the right one.
|
||||
await expect(page.locator("[data-toast-notification]")).toHaveCount(1);
|
||||
await expect(page.getByText("Failed to connect provider")).toHaveCount(0);
|
||||
|
||||
// ---- styled as a failure, not a success ----
|
||||
// notify() paints the icon tile green with a check unless the caller
|
||||
// says otherwise, which is how a refusal once looked like a success.
|
||||
//
|
||||
// A failure reaches the tile two ways — notify()'s own error state, and
|
||||
// a caller passing its own colour and icon, which is what the shared
|
||||
// request-failed toast does — so what has to hold is the negative: this
|
||||
// is not the default success tile.
|
||||
const icon = page.getByTestId("notification-icon");
|
||||
await expect(icon).toBeVisible();
|
||||
await expect(icon).not.toHaveAttribute("data-variant", "success");
|
||||
|
||||
// ---- the form is still there, still holding what was typed ----
|
||||
// The submit only exists while the modal is open, so its presence is the
|
||||
// check that nothing closed underneath the toast. It also has to come
|
||||
// back out of the in-flight state, or the retry the toast asks for is
|
||||
// impossible.
|
||||
await expect(submit).toBeVisible();
|
||||
await expect(submit).toBeEnabled();
|
||||
await expect(submit).toContainText("Connect Provider");
|
||||
await page.getByRole("tab", { name: "Provider" }).click({ force: true });
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-name"),
|
||||
).toHaveValue(providerName);
|
||||
// The key matters most: the API never returns one, so a form that lost
|
||||
// it leaves the operator with nothing to correct.
|
||||
await expect(
|
||||
page.getByTestId("agent-network-provider-api-key"),
|
||||
).toHaveValue("sk-e2e-refused-key");
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -364,13 +364,17 @@ test.describe.serial("Control Center Draft Matrix @control-center", () => {
|
||||
}) => {
|
||||
const group = await place(page, "group", 0.6, 0.5);
|
||||
await expectChangeCount(page, 1);
|
||||
// React Flow ignores delete keys while an input is focused, and the panel's
|
||||
// search takes focus on click, so blur it before pressing Backspace.
|
||||
await group.click();
|
||||
await expect(group).toHaveClass(/selected/);
|
||||
await page.evaluate(
|
||||
() => (document.activeElement as HTMLElement | null)?.blur(),
|
||||
);
|
||||
// React Flow ignores delete keys while an input is focused, and the panel
|
||||
// the click opens takes focus into its search — from an effect, about
|
||||
// 100ms later. Blurring before that lands leaves the input to claim focus
|
||||
// afterwards and swallow the Backspace, so wait for the focus, then drop
|
||||
// it.
|
||||
const panelSearch = page.locator("#cc-group-panel input").first();
|
||||
await expect(panelSearch).toBeFocused();
|
||||
await panelSearch.blur();
|
||||
await expect(panelSearch).not.toBeFocused();
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(canvasNode(page, "group-new-")).toHaveCount(0);
|
||||
await expectChangeCount(page, 0);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
dismissBlockingOverlays,
|
||||
enterDraft,
|
||||
openControlCenter,
|
||||
openGroupView,
|
||||
resetDraftState,
|
||||
reviewButton,
|
||||
} from "../helpers/control-center";
|
||||
@@ -37,7 +38,6 @@ test.describe
|
||||
await deleteGroupsByPrefix(page, PREFIX);
|
||||
});
|
||||
|
||||
// Clean-slated so the draft group view's auto-select is deterministic.
|
||||
async function seedPolicyGroupView(page: Page) {
|
||||
await deletePoliciesBySubstring(page, PREFIX);
|
||||
await deleteGroupsByPrefix(page, PREFIX);
|
||||
@@ -49,7 +49,9 @@ test.describe
|
||||
src.id,
|
||||
dst.id,
|
||||
);
|
||||
await openControlCenter(page, "groups");
|
||||
// Selected by name: the view's own pick ranks every group in the account,
|
||||
// so a peer-bearing group from another spec on the other worker wins it.
|
||||
await openGroupView(page, src.name);
|
||||
await enterDraft(page);
|
||||
const policyNode = canvasNode(page, `policy-${policy.id}`);
|
||||
await expect(policyNode).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
Loaded 20 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user