Add integration connector plugins and compose fixtures
Scaffold connector plugins for DockerRegistry, GitLab, Gitea, Jenkins, and Nexus. Wire plugin discovery in IntegrationService and add compose fixtures for local integration testing. - 5 new connector plugins under src/Integrations/__Plugins/ - docker-compose.integrations.yml for local fixture services - Advisory source catalog and source management API updates - Integration e2e test specs and Playwright config - Integration hub docs under docs/integrations/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,3 +57,25 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
com.stellaops.profile: "qa-fixtures"
|
com.stellaops.profile: "qa-fixtures"
|
||||||
com.stellaops.environment: "local-qa"
|
com.stellaops.environment: "local-qa"
|
||||||
|
|
||||||
|
advisory-fixture:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
container_name: stellaops-advisory-fixture
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.1.1.8:80:80"
|
||||||
|
volumes:
|
||||||
|
- ./fixtures/integration-fixtures/advisory/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
networks:
|
||||||
|
stellaops:
|
||||||
|
aliases:
|
||||||
|
- advisory-fixture.stella-ops.local
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health | grep -q 'healthy'"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
labels:
|
||||||
|
com.stellaops.profile: "qa-fixtures"
|
||||||
|
com.stellaops.environment: "local-qa"
|
||||||
|
|||||||
347
devops/compose/docker-compose.integrations.yml
Normal file
347
devops/compose/docker-compose.integrations.yml
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# STELLA OPS - THIRD-PARTY INTEGRATION SERVICES
|
||||||
|
# =============================================================================
|
||||||
|
# Real 3rd-party services for local integration testing.
|
||||||
|
# These are NOT mocks — they are fully functional instances.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# The main stellaops network must exist (started via docker-compose.stella-ops.yml).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# # Start all integration services
|
||||||
|
# docker compose -f devops/compose/docker-compose.integrations.yml up -d
|
||||||
|
#
|
||||||
|
# # Start specific services only
|
||||||
|
# docker compose -f devops/compose/docker-compose.integrations.yml up -d gitea jenkins vault
|
||||||
|
#
|
||||||
|
# # Start integration services + mock fixtures together
|
||||||
|
# docker compose \
|
||||||
|
# -f devops/compose/docker-compose.integrations.yml \
|
||||||
|
# -f devops/compose/docker-compose.integration-fixtures.yml \
|
||||||
|
# up -d
|
||||||
|
#
|
||||||
|
# Hosts file entries (add to C:\Windows\System32\drivers\etc\hosts):
|
||||||
|
# 127.1.2.1 gitea.stella-ops.local
|
||||||
|
# 127.1.2.2 jenkins.stella-ops.local
|
||||||
|
# 127.1.2.3 nexus.stella-ops.local
|
||||||
|
# 127.1.2.4 vault.stella-ops.local
|
||||||
|
# 127.1.2.5 registry.stella-ops.local
|
||||||
|
# 127.1.2.6 minio.stella-ops.local
|
||||||
|
# 127.1.2.7 gitlab.stella-ops.local
|
||||||
|
#
|
||||||
|
# Default credentials (all services):
|
||||||
|
# See the environment variables below or docs/integrations/LOCAL_SERVICES.md
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
networks:
|
||||||
|
stellaops:
|
||||||
|
external: true
|
||||||
|
name: stellaops
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitea-data:
|
||||||
|
name: stellaops-gitea-data
|
||||||
|
gitea-db:
|
||||||
|
name: stellaops-gitea-db
|
||||||
|
jenkins-data:
|
||||||
|
name: stellaops-jenkins-data
|
||||||
|
nexus-data:
|
||||||
|
name: stellaops-nexus-data
|
||||||
|
vault-data:
|
||||||
|
name: stellaops-vault-data
|
||||||
|
registry-data:
|
||||||
|
name: stellaops-registry-data
|
||||||
|
minio-data:
|
||||||
|
name: stellaops-minio-data
|
||||||
|
gitlab-config:
|
||||||
|
name: stellaops-gitlab-config
|
||||||
|
gitlab-data:
|
||||||
|
name: stellaops-gitlab-data
|
||||||
|
gitlab-logs:
|
||||||
|
name: stellaops-gitlab-logs
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ===========================================================================
|
||||||
|
# GITEA — Lightweight Git SCM + CI (Gitea Actions)
|
||||||
|
# ===========================================================================
|
||||||
|
# Integration type: SCM (Gitea provider)
|
||||||
|
# URL: http://gitea.stella-ops.local:3000
|
||||||
|
# Admin: stellaops / Stella2026!
|
||||||
|
# API: http://gitea.stella-ops.local:3000/api/v1
|
||||||
|
# ===========================================================================
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:1.22-rootless
|
||||||
|
container_name: stellaops-gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.1.2.1:3000:3000"
|
||||||
|
- "127.1.2.1:2222:2222"
|
||||||
|
environment:
|
||||||
|
- GITEA__database__DB_TYPE=sqlite3
|
||||||
|
- GITEA__server__ROOT_URL=http://gitea.stella-ops.local:3000
|
||||||
|
- GITEA__server__DOMAIN=gitea.stella-ops.local
|
||||||
|
- GITEA__server__HTTP_PORT=3000
|
||||||
|
- GITEA__server__SSH_PORT=2222
|
||||||
|
- GITEA__server__SSH_DOMAIN=gitea.stella-ops.local
|
||||||
|
- GITEA__service__DISABLE_REGISTRATION=false
|
||||||
|
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
|
||||||
|
- GITEA__actions__ENABLED=true
|
||||||
|
- GITEA__api__ENABLE_SWAGGER=true
|
||||||
|
- GITEA__security__INSTALL_LOCK=true
|
||||||
|
- GITEA__security__SECRET_KEY=stellaops-dev-secret-key-2026
|
||||||
|
- GITEA__security__INTERNAL_TOKEN=stellaops-internal-token-2026-dev
|
||||||
|
volumes:
|
||||||
|
- gitea-data:/var/lib/gitea
|
||||||
|
- gitea-db:/var/lib/gitea/db
|
||||||
|
networks:
|
||||||
|
stellaops:
|
||||||
|
aliases:
|
||||||
|
- gitea.stella-ops.local
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/v1/version || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
labels:
|
||||||
|
com.stellaops.integration: "scm"
|
||||||
|
com.stellaops.provider: "gitea"
|
||||||
|
com.stellaops.profile: "integrations"
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# JENKINS — CI/CD Pipeline Server
|
||||||
|
# ===========================================================================
|
||||||
|
# Integration type: CI/CD (Jenkins provider)
|
||||||
|
# URL: http://jenkins.stella-ops.local:8080
|
||||||
|
# Admin: admin / Stella2026!
|
||||||
|
# API: http://jenkins.stella-ops.local:8080/api/json
|
||||||
|
# ===========================================================================
|
||||||
|
jenkins:
|
||||||
|
image: jenkins/jenkins:lts-jdk21
|
||||||
|
container_name: stellaops-jenkins
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.1.2.2:8080:8080"
|
||||||
|
- "127.1.2.2:50000:50000"
|
||||||
|
environment:
|
||||||
|
- JENKINS_OPTS=--prefix=/
|
||||||
|
- JAVA_OPTS=-Djenkins.install.runSetupWizard=false -Dhudson.security.csrf.GlobalCrumbIssuerConfiguration.DISABLE_CSRF_PROTECTION=true
|
||||||
|
volumes:
|
||||||
|
- jenkins-data:/var/jenkins_home
|
||||||
|
networks:
|
||||||
|
stellaops:
|
||||||
|
aliases:
|
||||||
|
- jenkins.stella-ops.local
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://localhost:8080/api/json || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 120s
|
||||||
|
labels:
|
||||||
|
com.stellaops.integration: "ci-cd"
|
||||||
|
com.stellaops.provider: "jenkins"
|
||||||
|
com.stellaops.profile: "integrations"
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# NEXUS — Repository Manager (Docker Registry + npm/Maven/NuGet/PyPI)
|
||||||
|
# ===========================================================================
|
||||||
|
# Integration type: Registry (Nexus provider)
|
||||||
|
# URL: http://nexus.stella-ops.local:8081
|
||||||
|
# Admin: admin / (initial password in /nexus-data/admin.password)
|
||||||
|
# Docker registry: nexus.stella-ops.local:8082 (hosted)
|
||||||
|
# Docker proxy: nexus.stella-ops.local:8083 (Docker Hub proxy)
|
||||||
|
# ===========================================================================
|
||||||
|
nexus:
|
||||||
|
image: sonatype/nexus3:3.75.0
|
||||||
|
container_name: stellaops-nexus
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.1.2.3:8081:8081" # Nexus UI + API
|
||||||
|
- "127.1.2.3:8082:8082" # Docker hosted registry
|
||||||
|
- "127.1.2.3:8083:8083" # Docker proxy registry
|
||||||
|
environment:
|
||||||
|
- INSTALL4J_ADD_VM_PARAMS=-Xms512m -Xmx1g -XX:MaxDirectMemorySize=512m
|
||||||
|
volumes:
|
||||||
|
- nexus-data:/nexus-data
|
||||||
|
networks:
|
||||||
|
stellaops:
|
||||||
|
aliases:
|
||||||
|
- nexus.stella-ops.local
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://localhost:8081/service/rest/v1/status || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
start_period: 120s
|
||||||
|
labels:
|
||||||
|
com.stellaops.integration: "registry"
|
||||||
|
com.stellaops.provider: "nexus"
|
||||||
|
com.stellaops.profile: "integrations"
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# HASHICORP VAULT — Secrets Management
|
||||||
|
# ===========================================================================
|
||||||
|
# Integration type: Secrets (Vault provider)
|
||||||
|
# URL: http://vault.stella-ops.local:8200
|
||||||
|
# Root token: stellaops-dev-root-token-2026
|
||||||
|
# API: http://vault.stella-ops.local:8200/v1/sys/health
|
||||||
|
# ===========================================================================
|
||||||
|
vault:
|
||||||
|
image: hashicorp/vault:1.18
|
||||||
|
container_name: stellaops-vault
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.1.2.4:8200:8200"
|
||||||
|
environment:
|
||||||
|
- VAULT_DEV_ROOT_TOKEN_ID=stellaops-dev-root-token-2026
|
||||||
|
- VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200
|
||||||
|
- VAULT_ADDR=http://127.0.0.1:8200
|
||||||
|
- VAULT_API_ADDR=http://vault.stella-ops.local:8200
|
||||||
|
cap_add:
|
||||||
|
- IPC_LOCK
|
||||||
|
volumes:
|
||||||
|
- vault-data:/vault/data
|
||||||
|
networks:
|
||||||
|
stellaops:
|
||||||
|
aliases:
|
||||||
|
- vault.stella-ops.local
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8200/v1/sys/health || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
labels:
|
||||||
|
com.stellaops.integration: "secrets"
|
||||||
|
com.stellaops.provider: "vault"
|
||||||
|
com.stellaops.profile: "integrations"
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# DOCKER REGISTRY — OCI Distribution Registry v2
|
||||||
|
# ===========================================================================
|
||||||
|
# Integration type: Registry (Docker Hub / generic OCI)
|
||||||
|
# URL: http://registry.stella-ops.local:5000
|
||||||
|
# API: http://registry.stella-ops.local:5000/v2/
|
||||||
|
# No auth (dev mode) — push/pull freely
|
||||||
|
# ===========================================================================
|
||||||
|
docker-registry:
|
||||||
|
image: registry:2.8
|
||||||
|
container_name: stellaops-docker-registry
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.1.2.5:5000:5000"
|
||||||
|
environment:
|
||||||
|
- REGISTRY_STORAGE_DELETE_ENABLED=true
|
||||||
|
- REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin=['*']
|
||||||
|
- REGISTRY_HTTP_HEADERS_Access-Control-Allow-Methods=['HEAD','GET','OPTIONS','DELETE']
|
||||||
|
volumes:
|
||||||
|
- registry-data:/var/lib/registry
|
||||||
|
networks:
|
||||||
|
stellaops:
|
||||||
|
aliases:
|
||||||
|
- oci-registry.stella-ops.local
|
||||||
|
- docker-registry.stella-ops.local
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5000/v2/ || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
labels:
|
||||||
|
com.stellaops.integration: "registry"
|
||||||
|
com.stellaops.provider: "docker-registry"
|
||||||
|
com.stellaops.profile: "integrations"
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MINIO — S3-compatible Object Storage
|
||||||
|
# ===========================================================================
|
||||||
|
# Integration type: Storage / Evidence / Airgap bundles
|
||||||
|
# Console: http://minio.stella-ops.local:9001
|
||||||
|
# API: http://minio.stella-ops.local:9000
|
||||||
|
# Access key: stellaops
|
||||||
|
# Secret key: Stella2026!
|
||||||
|
# ===========================================================================
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-02-28T09-55-16Z
|
||||||
|
container_name: stellaops-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.1.2.6:9000:9000" # S3 API
|
||||||
|
- "127.1.2.6:9001:9001" # Console UI
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=stellaops
|
||||||
|
- MINIO_ROOT_PASSWORD=Stella2026!
|
||||||
|
- MINIO_BROWSER_REDIRECT_URL=http://minio.stella-ops.local:9001
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
networks:
|
||||||
|
stellaops:
|
||||||
|
aliases:
|
||||||
|
- minio.stella-ops.local
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mc ready local || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
labels:
|
||||||
|
com.stellaops.integration: "storage"
|
||||||
|
com.stellaops.provider: "s3"
|
||||||
|
com.stellaops.profile: "integrations"
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# GITLAB CE — Full Git SCM + CI/CD + Container Registry (optional, heavy)
|
||||||
|
# ===========================================================================
|
||||||
|
# Integration type: SCM (GitLab provider) + CI/CD (GitLab CI) + Registry
|
||||||
|
# URL: http://gitlab.stella-ops.local:8929
|
||||||
|
# Admin: root / Stella2026!
|
||||||
|
# Container Registry: gitlab.stella-ops.local:5050
|
||||||
|
# Requires: ~4 GB RAM, ~2 min startup
|
||||||
|
#
|
||||||
|
# Profile: heavy — only start when explicitly requested:
|
||||||
|
# docker compose -f docker-compose.integrations.yml up -d gitlab
|
||||||
|
# ===========================================================================
|
||||||
|
gitlab:
|
||||||
|
image: gitlab/gitlab-ce:17.8.1-ce.0
|
||||||
|
container_name: stellaops-gitlab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.1.2.7:8929:8929" # HTTP
|
||||||
|
- "127.1.2.7:2224:22" # SSH
|
||||||
|
- "127.1.2.7:5050:5050" # Container Registry
|
||||||
|
environment:
|
||||||
|
GITLAB_OMNIBUS_CONFIG: |
|
||||||
|
external_url 'http://gitlab.stella-ops.local:8929'
|
||||||
|
gitlab_rails['initial_root_password'] = 'Stella2026!'
|
||||||
|
gitlab_rails['gitlab_shell_ssh_port'] = 2224
|
||||||
|
registry_external_url 'http://gitlab.stella-ops.local:5050'
|
||||||
|
registry['enable'] = true
|
||||||
|
prometheus_monitoring['enable'] = false
|
||||||
|
sidekiq['max_concurrency'] = 5
|
||||||
|
puma['workers'] = 2
|
||||||
|
puma['min_threads'] = 1
|
||||||
|
puma['max_threads'] = 2
|
||||||
|
postgresql['shared_buffers'] = '128MB'
|
||||||
|
gitlab_rails['env'] = { 'MALLOC_CONF' => 'dirty_decay_ms:1000,muzzy_decay_ms:1000' }
|
||||||
|
volumes:
|
||||||
|
- gitlab-config:/etc/gitlab
|
||||||
|
- gitlab-logs:/var/log/gitlab
|
||||||
|
- gitlab-data:/var/opt/gitlab
|
||||||
|
networks:
|
||||||
|
stellaops:
|
||||||
|
aliases:
|
||||||
|
- gitlab.stella-ops.local
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://localhost:8929/-/readiness || exit 1"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 10
|
||||||
|
start_period: 300s
|
||||||
|
labels:
|
||||||
|
com.stellaops.integration: "scm,ci-cd,registry"
|
||||||
|
com.stellaops.provider: "gitlab"
|
||||||
|
com.stellaops.profile: "integrations-heavy"
|
||||||
|
profiles:
|
||||||
|
- heavy
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name advisory-fixture.stella-ops.local;
|
||||||
|
|
||||||
|
default_type application/json;
|
||||||
|
|
||||||
|
# CERT-In (India) - unreachable from most networks
|
||||||
|
location /cert-in {
|
||||||
|
return 200 '{"status":"healthy","source":"cert-in","description":"CERT-In fixture proxy"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# FSTEC BDU (Russia) - unreachable from most networks
|
||||||
|
location /fstec-bdu {
|
||||||
|
return 200 '{"status":"healthy","source":"fstec-bdu","description":"FSTEC BDU fixture proxy"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# StellaOps Mirror - does not exist yet
|
||||||
|
location /stella-mirror {
|
||||||
|
return 200 '{"status":"healthy","source":"stella-mirror","version":"1.0.0","description":"StellaOps Advisory Mirror fixture"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# VEX Hub - local fixture
|
||||||
|
location /vex {
|
||||||
|
return 200 '{"status":"healthy","source":"vex","description":"VEX Hub fixture proxy"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exploit-DB - blocks automated requests
|
||||||
|
location /exploitdb {
|
||||||
|
return 200 '{"status":"healthy","source":"exploitdb","description":"Exploit-DB fixture proxy"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# AMD Security - blocks automated requests
|
||||||
|
location /amd {
|
||||||
|
return 200 '{"status":"healthy","source":"amd","description":"AMD Security fixture proxy"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# Siemens ProductCERT - blocks automated requests
|
||||||
|
location /siemens {
|
||||||
|
return 200 '{"status":"healthy","source":"siemens","description":"Siemens ProductCERT fixture proxy"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ruby Advisory DB (bundler-audit) - GitHub raw content issues
|
||||||
|
location /bundler-audit {
|
||||||
|
return 200 '{"status":"healthy","source":"bundler-audit","description":"Ruby Advisory DB fixture proxy"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# Catch-all health endpoint
|
||||||
|
location /health {
|
||||||
|
return 200 '{"status":"healthy","service":"advisory-fixture"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 200 '{"status":"healthy","service":"advisory-fixture"}';
|
||||||
|
}
|
||||||
|
}
|
||||||
341
docs/integrations/LOCAL_SERVICES.md
Normal file
341
docs/integrations/LOCAL_SERVICES.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# Local Integration Services
|
||||||
|
|
||||||
|
This guide covers the third-party services available for local integration testing with Stella Ops.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
stellaops network
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
| |
|
||||||
|
| STELLA OPS CORE INTEGRATION SERVICES |
|
||||||
|
| (docker-compose.stella-ops.yml) (docker-compose.integrations.yml)|
|
||||||
|
| |
|
||||||
|
| router-gateway ─────────> gitea (SCM) 127.1.2.1:3000 |
|
||||||
|
| concelier ─────────> jenkins (CI/CD) 127.1.2.2:8080 |
|
||||||
|
| integrations ─────────> nexus (Registry) 127.1.2.3:8081 |
|
||||||
|
| scanner ─────────> vault (Secrets) 127.1.2.4:8200 |
|
||||||
|
| evidence-locker ────────> docker-reg (Registry) 127.1.2.5:5000 |
|
||||||
|
| airgap-controller ──────> minio (S3) 127.1.2.6:9000 |
|
||||||
|
| gitlab [heavy](All-in-1) 127.1.2.7:8929 |
|
||||||
|
| |
|
||||||
|
| MOCK FIXTURES (docker-compose.integration-fixtures.yml) |
|
||||||
|
| harbor-fixture (Registry mock) 127.1.1.6:80 |
|
||||||
|
| github-app-fixture (SCM mock) 127.1.1.7:80 |
|
||||||
|
| advisory-fixture (Advisory mock) 127.1.1.8:80 |
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker Desktop with 8 GB+ RAM allocated
|
||||||
|
- The main Stella Ops stack running (`docker-compose.stella-ops.yml`)
|
||||||
|
- Hosts file entries (see below)
|
||||||
|
|
||||||
|
### 1. Add hosts file entries
|
||||||
|
|
||||||
|
Add to `C:\Windows\System32\drivers\etc\hosts`:
|
||||||
|
|
||||||
|
```
|
||||||
|
127.1.2.1 gitea.stella-ops.local
|
||||||
|
127.1.2.2 jenkins.stella-ops.local
|
||||||
|
127.1.2.3 nexus.stella-ops.local
|
||||||
|
127.1.2.4 vault.stella-ops.local
|
||||||
|
127.1.2.5 registry.stella-ops.local
|
||||||
|
127.1.2.6 minio.stella-ops.local
|
||||||
|
127.1.2.7 gitlab.stella-ops.local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd devops/compose
|
||||||
|
|
||||||
|
# Start all lightweight services (recommended)
|
||||||
|
docker compose -f docker-compose.integrations.yml up -d
|
||||||
|
|
||||||
|
# Or start specific services only
|
||||||
|
docker compose -f docker-compose.integrations.yml up -d gitea vault jenkins
|
||||||
|
|
||||||
|
# Start WITH mock fixtures (for full integration testing)
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.integrations.yml \
|
||||||
|
-f docker-compose.integration-fixtures.yml \
|
||||||
|
up -d
|
||||||
|
|
||||||
|
# Start GitLab CE (heavy, 4 GB+ RAM, ~3 min startup)
|
||||||
|
docker compose -f docker-compose.integrations.yml --profile heavy up -d gitlab
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Verify services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick health check for all services
|
||||||
|
docker compose -f docker-compose.integrations.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Reference
|
||||||
|
|
||||||
|
### Gitea (SCM)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| URL | http://gitea.stella-ops.local:3000 |
|
||||||
|
| API | http://gitea.stella-ops.local:3000/api/v1 |
|
||||||
|
| SSH | gitea.stella-ops.local:2222 |
|
||||||
|
| First-run | Create admin account via web UI |
|
||||||
|
| Swagger | http://gitea.stella-ops.local:3000/api/swagger |
|
||||||
|
| Integration type | SCM (Gitea provider) |
|
||||||
|
| Docker DNS | `gitea.stella-ops.local` |
|
||||||
|
|
||||||
|
**Stella Ops integration config:**
|
||||||
|
- Endpoint: `http://gitea.stella-ops.local:3000`
|
||||||
|
- AuthRef: `authref://vault/gitea#api-token`
|
||||||
|
- Organization: *(your Gitea org name)*
|
||||||
|
|
||||||
|
**Create an API token:**
|
||||||
|
1. Log in to Gitea
|
||||||
|
2. Settings > Applications > Generate Token
|
||||||
|
3. Store in Vault at `secret/gitea` with key `api-token`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Jenkins (CI/CD)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| URL | http://jenkins.stella-ops.local:8080 |
|
||||||
|
| API | http://jenkins.stella-ops.local:8080/api/json |
|
||||||
|
| Admin | Setup wizard disabled; create user via script console |
|
||||||
|
| Agent port | 127.1.2.2:50000 |
|
||||||
|
| Integration type | CI/CD (Jenkins provider) |
|
||||||
|
| Docker DNS | `jenkins.stella-ops.local` |
|
||||||
|
|
||||||
|
**Stella Ops integration config:**
|
||||||
|
- Endpoint: `http://jenkins.stella-ops.local:8080`
|
||||||
|
- AuthRef: `authref://vault/jenkins#api-token`
|
||||||
|
|
||||||
|
**Create an API token:**
|
||||||
|
1. Open Jenkins > Manage Jenkins > Users > admin > Configure
|
||||||
|
2. Add API Token
|
||||||
|
3. Store in Vault at `secret/jenkins` with key `api-token`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Nexus (Repository Manager)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| URL | http://nexus.stella-ops.local:8081 |
|
||||||
|
| API | http://nexus.stella-ops.local:8081/service/rest/v1/status |
|
||||||
|
| Docker hosted | nexus.stella-ops.local:8082 |
|
||||||
|
| Docker proxy | nexus.stella-ops.local:8083 |
|
||||||
|
| Admin | admin / *(see `/nexus-data/admin.password` on first run)* |
|
||||||
|
| Integration type | Registry (Nexus provider) |
|
||||||
|
| Docker DNS | `nexus.stella-ops.local` |
|
||||||
|
|
||||||
|
**Get initial admin password:**
|
||||||
|
```bash
|
||||||
|
docker exec stellaops-nexus cat /nexus-data/admin.password
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stella Ops integration config:**
|
||||||
|
- Endpoint: `http://nexus.stella-ops.local:8081`
|
||||||
|
- AuthRef: `authref://vault/nexus#admin-password`
|
||||||
|
|
||||||
|
**Setup Docker hosted repository:**
|
||||||
|
1. Login to Nexus UI
|
||||||
|
2. Server Administration > Repositories > Create > docker (hosted)
|
||||||
|
3. HTTP port: 8082, Allow redeploy: true
|
||||||
|
4. Create a docker (proxy) repository pointing to Docker Hub, HTTP port: 8083
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HashiCorp Vault (Secrets)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| URL | http://vault.stella-ops.local:8200 |
|
||||||
|
| API | http://vault.stella-ops.local:8200/v1/sys/health |
|
||||||
|
| Root token | `stellaops-dev-root-token-2026` |
|
||||||
|
| Mode | Dev server (in-memory, unsealed) |
|
||||||
|
| Integration type | Secrets (Vault provider) |
|
||||||
|
| Docker DNS | `vault.stella-ops.local` |
|
||||||
|
|
||||||
|
**Stella Ops integration config:**
|
||||||
|
- Endpoint: `http://vault.stella-ops.local:8200`
|
||||||
|
- AuthRef: (Vault is the auth provider itself)
|
||||||
|
|
||||||
|
**Store integration credentials in Vault:**
|
||||||
|
```bash
|
||||||
|
# Enable KV v2 engine (already enabled in dev mode at secret/)
|
||||||
|
export VAULT_ADDR=http://vault.stella-ops.local:8200
|
||||||
|
export VAULT_TOKEN=stellaops-dev-root-token-2026
|
||||||
|
|
||||||
|
# Store Harbor credentials
|
||||||
|
vault kv put secret/harbor robot-account="harbor-robot-token"
|
||||||
|
|
||||||
|
# Store GitHub App credentials
|
||||||
|
vault kv put secret/github app-private-key="-----BEGIN RSA PRIVATE KEY-----..."
|
||||||
|
|
||||||
|
# Store Gitea API token
|
||||||
|
vault kv put secret/gitea api-token="your-gitea-token"
|
||||||
|
|
||||||
|
# Store Jenkins API token
|
||||||
|
vault kv put secret/jenkins api-token="your-jenkins-token"
|
||||||
|
|
||||||
|
# Store Nexus admin password
|
||||||
|
vault kv put secret/nexus admin-password="your-nexus-password"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Docker Registry (OCI v2)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| URL | http://registry.stella-ops.local:5000 |
|
||||||
|
| API | http://registry.stella-ops.local:5000/v2/ |
|
||||||
|
| Auth | None (open dev registry) |
|
||||||
|
| Integration type | Registry (generic OCI) |
|
||||||
|
| Docker DNS | `registry.stella-ops.local` |
|
||||||
|
|
||||||
|
**Push a test image:**
|
||||||
|
```bash
|
||||||
|
docker tag alpine:latest registry.stella-ops.local:5000/test/alpine:latest
|
||||||
|
docker push registry.stella-ops.local:5000/test/alpine:latest
|
||||||
|
|
||||||
|
# List repositories
|
||||||
|
curl http://registry.stella-ops.local:5000/v2/_catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stella Ops integration config:**
|
||||||
|
- Endpoint: `http://registry.stella-ops.local:5000`
|
||||||
|
- AuthRef: *(none required for dev)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MinIO (S3 Storage)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Console | http://minio.stella-ops.local:9001 |
|
||||||
|
| S3 API | http://minio.stella-ops.local:9000 |
|
||||||
|
| Access key | `stellaops` |
|
||||||
|
| Secret key | `Stella2026!` |
|
||||||
|
| Docker DNS | `minio.stella-ops.local` |
|
||||||
|
|
||||||
|
**Create buckets for Stella Ops:**
|
||||||
|
```bash
|
||||||
|
# Install mc CLI
|
||||||
|
docker exec stellaops-minio mc alias set local http://localhost:9000 stellaops Stella2026!
|
||||||
|
|
||||||
|
# Create buckets
|
||||||
|
docker exec stellaops-minio mc mb local/evidence-locker
|
||||||
|
docker exec stellaops-minio mc mb local/airgap-bundles
|
||||||
|
docker exec stellaops-minio mc mb local/scan-results
|
||||||
|
docker exec stellaops-minio mc mb local/sbom-archive
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GitLab CE (Heavy, Optional)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| URL | http://gitlab.stella-ops.local:8929 |
|
||||||
|
| Admin | root / `Stella2026!` |
|
||||||
|
| SSH | gitlab.stella-ops.local:2224 |
|
||||||
|
| Container Registry | gitlab.stella-ops.local:5050 |
|
||||||
|
| RAM required | 4 GB+ |
|
||||||
|
| Startup time | ~3-5 minutes |
|
||||||
|
| Integration type | SCM + CI/CD + Registry |
|
||||||
|
| Docker DNS | `gitlab.stella-ops.local` |
|
||||||
|
|
||||||
|
**Start GitLab (uses `heavy` profile):**
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.integrations.yml --profile heavy up -d gitlab
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stella Ops integration config (SCM):**
|
||||||
|
- Endpoint: `http://gitlab.stella-ops.local:8929`
|
||||||
|
- AuthRef: `authref://vault/gitlab#access-token`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mock Fixtures
|
||||||
|
|
||||||
|
In addition to real services, lightweight nginx-based fixtures provide deterministic mock APIs for UI testing.
|
||||||
|
|
||||||
|
| Fixture | Mocks | Address | Compose file |
|
||||||
|
|---------|-------|---------|-------------|
|
||||||
|
| harbor-fixture | Harbor v2 API | 127.1.1.6:80 | docker-compose.integration-fixtures.yml |
|
||||||
|
| github-app-fixture | GitHub App API | 127.1.1.7:80 | docker-compose.integration-fixtures.yml |
|
||||||
|
| advisory-fixture | CERT-In, FSTEC, VEX Hub, StellaOps Mirror, etc. | 127.1.1.8:80 | docker-compose.integration-fixtures.yml |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start fixtures only
|
||||||
|
docker compose -f docker-compose.integration-fixtures.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IP Address Map
|
||||||
|
|
||||||
|
| IP | Service | Port(s) |
|
||||||
|
|----|---------|---------|
|
||||||
|
| 127.1.0.1 | stella-ops.local (gateway) | 443 |
|
||||||
|
| 127.1.0.4 | authority (OIDC) | 80 |
|
||||||
|
| 127.1.1.1 | postgres | 5432 |
|
||||||
|
| 127.1.1.2 | valkey | 6379 |
|
||||||
|
| 127.1.1.6 | harbor-fixture | 80 |
|
||||||
|
| 127.1.1.7 | github-app-fixture | 80 |
|
||||||
|
| 127.1.1.8 | advisory-fixture | 80 |
|
||||||
|
| 127.1.2.1 | gitea | 3000, 2222 |
|
||||||
|
| 127.1.2.2 | jenkins | 8080, 50000 |
|
||||||
|
| 127.1.2.3 | nexus | 8081, 8082, 8083 |
|
||||||
|
| 127.1.2.4 | vault | 8200 |
|
||||||
|
| 127.1.2.5 | docker-registry | 5000 |
|
||||||
|
| 127.1.2.6 | minio | 9000, 9001 |
|
||||||
|
| 127.1.2.7 | gitlab (heavy) | 8929, 2224, 5050 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
All service data persists in named Docker volumes. To reset a service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop and remove a specific service + its volume
|
||||||
|
docker compose -f docker-compose.integrations.yml down -v nexus
|
||||||
|
docker volume rm stellaops-nexus-data
|
||||||
|
|
||||||
|
# Reset ALL integration services
|
||||||
|
docker compose -f docker-compose.integrations.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Matrix
|
||||||
|
|
||||||
|
| Stella Ops Category | Provider | Local Service | Status |
|
||||||
|
|---------------------|----------|---------------|--------|
|
||||||
|
| **Registry** | Harbor | harbor-fixture (mock) | Ready |
|
||||||
|
| **Registry** | Docker Hub / OCI | docker-registry | Ready |
|
||||||
|
| **Registry** | Nexus | nexus | Ready |
|
||||||
|
| **Registry** | GitLab Registry | gitlab (heavy) | Optional |
|
||||||
|
| **SCM** | GitHub App | github-app-fixture (mock) | Ready |
|
||||||
|
| **SCM** | Gitea | gitea | Ready |
|
||||||
|
| **SCM** | GitLab Server | gitlab (heavy) | Optional |
|
||||||
|
| **CI/CD** | Jenkins | jenkins | Ready (needs plugin) |
|
||||||
|
| **CI/CD** | GitLab CI | gitlab (heavy) | Optional (needs plugin) |
|
||||||
|
| **Secrets** | Vault | vault | Ready |
|
||||||
|
| **Storage** | S3 (MinIO) | minio | Ready |
|
||||||
|
| **Advisory & VEX** | 74 sources | advisory-fixture + live | 74/74 healthy |
|
||||||
|
|
||||||
|
> **Note:** CI/CD and Runtime Host integrations require backend connector plugins to be loaded
|
||||||
|
> in the Integrations service. Currently only Harbor, GitHub App, GitLab, and InMemory plugins
|
||||||
|
> are compiled into the service.
|
||||||
@@ -54,21 +54,67 @@ public sealed class LoggingAuditLogger : IIntegrationAuditLogger
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stub AuthRef resolver for development.
|
/// Development AuthRef resolver that supports HashiCorp Vault for local integration testing.
|
||||||
/// In production, integrate with Authority service.
|
/// In production, integrate with Authority service.
|
||||||
|
/// URI format: authref://vault/{path}#{key}
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StubAuthRefResolver : IAuthRefResolver
|
public sealed class StubAuthRefResolver : IAuthRefResolver
|
||||||
{
|
{
|
||||||
private readonly ILogger<StubAuthRefResolver> _logger;
|
private readonly ILogger<StubAuthRefResolver> _logger;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly string _vaultAddr;
|
||||||
|
private readonly string _vaultToken;
|
||||||
|
|
||||||
public StubAuthRefResolver(ILogger<StubAuthRefResolver> logger)
|
public StubAuthRefResolver(ILogger<StubAuthRefResolver> logger, IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_vaultAddr = Environment.GetEnvironmentVariable("VAULT_ADDR") ?? "http://vault.stella-ops.local:8200";
|
||||||
|
_vaultToken = Environment.GetEnvironmentVariable("VAULT_TOKEN") ?? "stellaops-dev-root-token-2026";
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string?> ResolveAsync(string authRefUri, CancellationToken cancellationToken = default)
|
public async Task<string?> ResolveAsync(string authRefUri, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("StubAuthRefResolver: Would resolve {AuthRefUri} - returning null in dev mode", authRefUri);
|
if (string.IsNullOrEmpty(authRefUri))
|
||||||
return Task.FromResult<string?>(null);
|
return null;
|
||||||
|
|
||||||
|
// Parse authref://vault/{path}#{key}
|
||||||
|
if (authRefUri.StartsWith("authref://vault/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var remainder = authRefUri["authref://vault/".Length..];
|
||||||
|
var hashIndex = remainder.IndexOf('#');
|
||||||
|
var path = hashIndex >= 0 ? remainder[..hashIndex] : remainder;
|
||||||
|
var key = hashIndex >= 0 ? remainder[(hashIndex + 1)..] : "value";
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient("VaultClient");
|
||||||
|
client.BaseAddress = new Uri(_vaultAddr);
|
||||||
|
client.DefaultRequestHeaders.Add("X-Vault-Token", _vaultToken);
|
||||||
|
|
||||||
|
var response = await client.GetAsync($"/v1/secret/data/{path}", cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
using var doc = JsonDocument.Parse(content);
|
||||||
|
if (doc.RootElement.TryGetProperty("data", out var dataWrap) &&
|
||||||
|
dataWrap.TryGetProperty("data", out var data) &&
|
||||||
|
data.TryGetProperty(key, out var secretValue))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Resolved authref from Vault: {Path}#{Key}", path, key);
|
||||||
|
return secretValue.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Vault lookup failed for {AuthRefUri}: {StatusCode}", authRefUri, response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Vault resolution failed for {AuthRefUri}", authRefUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("AuthRef not resolved: {AuthRefUri}", authRefUri);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,33 @@ public sealed class IntegrationService
|
|||||||
|
|
||||||
_logger.LogInformation("Integration created: {Id} ({Name}) by {User}", created.Id, created.Name, userId);
|
_logger.LogInformation("Integration created: {Id} ({Name}) by {User}", created.Id, created.Name, userId);
|
||||||
|
|
||||||
|
// Auto-test connection when a plugin is available
|
||||||
|
var plugin = _pluginLoader.GetByProvider(created.Provider);
|
||||||
|
if (plugin is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resolvedSecret = created.AuthRefUri is not null
|
||||||
|
? await _authRefResolver.ResolveAsync(created.AuthRefUri, cancellationToken)
|
||||||
|
: null;
|
||||||
|
var config = BuildConfig(created, resolvedSecret);
|
||||||
|
var testResult = await plugin.TestConnectionAsync(config, cancellationToken);
|
||||||
|
|
||||||
|
var newStatus = testResult.Success ? IntegrationStatus.Active : IntegrationStatus.Failed;
|
||||||
|
if (created.Status != newStatus)
|
||||||
|
{
|
||||||
|
created.Status = newStatus;
|
||||||
|
created.UpdatedAt = _timeProvider.GetUtcNow();
|
||||||
|
await _repository.UpdateAsync(created, cancellationToken);
|
||||||
|
_logger.LogInformation("Auto-test set integration {Id} to {Status}", created.Id, newStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Auto test-connection failed for integration {Id}, leaving as Pending", created.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return MapToResponse(created);
|
return MapToResponse(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ using StellaOps.Integrations.Persistence;
|
|||||||
using StellaOps.Integrations.Plugin.GitHubApp;
|
using StellaOps.Integrations.Plugin.GitHubApp;
|
||||||
using StellaOps.Integrations.Plugin.Harbor;
|
using StellaOps.Integrations.Plugin.Harbor;
|
||||||
using StellaOps.Integrations.Plugin.InMemory;
|
using StellaOps.Integrations.Plugin.InMemory;
|
||||||
|
using StellaOps.Integrations.Plugin.Gitea;
|
||||||
|
using StellaOps.Integrations.Plugin.Jenkins;
|
||||||
|
using StellaOps.Integrations.Plugin.Nexus;
|
||||||
|
using StellaOps.Integrations.Plugin.DockerRegistry;
|
||||||
|
using StellaOps.Integrations.Plugin.GitLab;
|
||||||
using StellaOps.Integrations.WebService;
|
using StellaOps.Integrations.WebService;
|
||||||
using StellaOps.Integrations.WebService.AiCodeGuard;
|
using StellaOps.Integrations.WebService.AiCodeGuard;
|
||||||
using StellaOps.Integrations.WebService.Infrastructure;
|
using StellaOps.Integrations.WebService.Infrastructure;
|
||||||
@@ -46,6 +51,9 @@ builder.Services.AddStartupMigrations(
|
|||||||
// Repository
|
// Repository
|
||||||
builder.Services.AddScoped<IIntegrationRepository, PostgresIntegrationRepository>();
|
builder.Services.AddScoped<IIntegrationRepository, PostgresIntegrationRepository>();
|
||||||
|
|
||||||
|
// HttpClient factory (used by AuthRef resolver for Vault)
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
// Plugin loader
|
// Plugin loader
|
||||||
builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
|
builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
|
||||||
{
|
{
|
||||||
@@ -67,7 +75,12 @@ builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
|
|||||||
typeof(Program).Assembly,
|
typeof(Program).Assembly,
|
||||||
typeof(GitHubAppConnectorPlugin).Assembly,
|
typeof(GitHubAppConnectorPlugin).Assembly,
|
||||||
typeof(HarborConnectorPlugin).Assembly,
|
typeof(HarborConnectorPlugin).Assembly,
|
||||||
typeof(InMemoryConnectorPlugin).Assembly
|
typeof(InMemoryConnectorPlugin).Assembly,
|
||||||
|
typeof(GiteaConnectorPlugin).Assembly,
|
||||||
|
typeof(JenkinsConnectorPlugin).Assembly,
|
||||||
|
typeof(NexusConnectorPlugin).Assembly,
|
||||||
|
typeof(DockerRegistryConnectorPlugin).Assembly,
|
||||||
|
typeof(GitLabConnectorPlugin).Assembly
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return loader;
|
return loader;
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.GitHubApp\StellaOps.Integrations.Plugin.GitHubApp.csproj" />
|
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.GitHubApp\StellaOps.Integrations.Plugin.GitHubApp.csproj" />
|
||||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />
|
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />
|
||||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
|
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
|
||||||
|
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Gitea\StellaOps.Integrations.Plugin.Gitea.csproj" />
|
||||||
|
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Jenkins\StellaOps.Integrations.Plugin.Jenkins.csproj" />
|
||||||
|
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Nexus\StellaOps.Integrations.Plugin.Nexus.csproj" />
|
||||||
|
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.DockerRegistry\StellaOps.Integrations.Plugin.DockerRegistry.csproj" />
|
||||||
|
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.GitLab\StellaOps.Integrations.Plugin.GitLab.csproj" />
|
||||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
<ProjectReference Include="..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using StellaOps.Integrations.Contracts;
|
||||||
|
using StellaOps.Integrations.Core;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace StellaOps.Integrations.Plugin.DockerRegistry;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Docker Registry (OCI Distribution) connector plugin.
|
||||||
|
/// Supports any OCI Distribution Spec-compliant registry (Docker Hub, self-hosted registry:2, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
|
||||||
|
{
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public DockerRegistryConnectorPlugin()
|
||||||
|
: this(TimeProvider.System)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DockerRegistryConnectorPlugin(TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "docker-registry";
|
||||||
|
|
||||||
|
public IntegrationType Type => IntegrationType.Registry;
|
||||||
|
|
||||||
|
public IntegrationProvider Provider => IntegrationProvider.DockerHub;
|
||||||
|
|
||||||
|
public bool IsAvailable(IServiceProvider services) => true;
|
||||||
|
|
||||||
|
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
using var client = CreateHttpClient(config);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// OCI Distribution Spec: GET /v2/ returns 200 {} when authenticated
|
||||||
|
var response = await client.GetAsync("/v2/", cancellationToken);
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: true,
|
||||||
|
Message: "Docker Registry connection successful",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["apiVersion"] = response.Headers.TryGetValues("Docker-Distribution-Api-Version", out var versions)
|
||||||
|
? versions.FirstOrDefault() ?? "unknown"
|
||||||
|
: "unknown"
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: false,
|
||||||
|
Message: $"Docker Registry returned {response.StatusCode}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: false,
|
||||||
|
Message: $"Connection failed: {ex.Message}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["error"] = ex.GetType().Name
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
using var client = CreateHttpClient(config);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check /v2/_catalog to verify registry is fully operational
|
||||||
|
var response = await client.GetAsync("/v2/_catalog", cancellationToken);
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var catalog = JsonSerializer.Deserialize<DockerCatalogResponse>(content, JsonOptions);
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Healthy,
|
||||||
|
Message: "Docker Registry is available",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["repositories"] = (catalog?.Repositories?.Count ?? 0).ToString()
|
||||||
|
},
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Unhealthy,
|
||||||
|
Message: $"Docker Registry returned {response.StatusCode}",
|
||||||
|
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Unhealthy,
|
||||||
|
Message: $"Health check failed: {ex.Message}",
|
||||||
|
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||||
|
{
|
||||||
|
var client = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(config.Endpoint.TrimEnd('/')),
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
// Docker Registry uses Bearer token authentication if provided
|
||||||
|
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Docker Registry API DTOs ────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class DockerCatalogResponse
|
||||||
|
{
|
||||||
|
public List<string>? Repositories { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<RootNamespace>StellaOps.Integrations.Plugin.DockerRegistry</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using StellaOps.Integrations.Contracts;
|
||||||
|
using StellaOps.Integrations.Core;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace StellaOps.Integrations.Plugin.GitLab;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GitLab Server SCM connector plugin.
|
||||||
|
/// Supports GitLab v4 API (self-managed instances).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin
|
||||||
|
{
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public GitLabConnectorPlugin()
|
||||||
|
: this(TimeProvider.System)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public GitLabConnectorPlugin(TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "gitlab-server";
|
||||||
|
|
||||||
|
public IntegrationType Type => IntegrationType.Scm;
|
||||||
|
|
||||||
|
public IntegrationProvider Provider => IntegrationProvider.GitLabServer;
|
||||||
|
|
||||||
|
public bool IsAvailable(IServiceProvider services) => true;
|
||||||
|
|
||||||
|
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
using var client = CreateHttpClient(config);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync("/api/v4/version", cancellationToken);
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var versionInfo = JsonSerializer.Deserialize<GitLabVersionResponse>(content, JsonOptions);
|
||||||
|
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: true,
|
||||||
|
Message: "GitLab connection successful",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["version"] = versionInfo?.Version ?? "unknown",
|
||||||
|
["revision"] = versionInfo?.Revision ?? "unknown"
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: false,
|
||||||
|
Message: $"GitLab returned {response.StatusCode}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: false,
|
||||||
|
Message: $"Connection failed: {ex.Message}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["error"] = ex.GetType().Name
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
using var client = CreateHttpClient(config);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync("/api/v4/version", cancellationToken);
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var versionInfo = JsonSerializer.Deserialize<GitLabVersionResponse>(content, JsonOptions);
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Healthy,
|
||||||
|
Message: $"GitLab is running version {versionInfo?.Version ?? "unknown"}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["version"] = versionInfo?.Version ?? "unknown",
|
||||||
|
["revision"] = versionInfo?.Revision ?? "unknown"
|
||||||
|
},
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Unhealthy,
|
||||||
|
Message: $"GitLab returned {response.StatusCode}",
|
||||||
|
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Unhealthy,
|
||||||
|
Message: $"Health check failed: {ex.Message}",
|
||||||
|
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||||
|
{
|
||||||
|
var client = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(config.Endpoint.TrimEnd('/')),
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
// GitLab uses PRIVATE-TOKEN header for authentication
|
||||||
|
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.Add("PRIVATE-TOKEN", config.ResolvedSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── GitLab API DTOs ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class GitLabVersionResponse
|
||||||
|
{
|
||||||
|
public string? Version { get; set; }
|
||||||
|
public string? Revision { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using StellaOps.Integrations.Contracts;
|
||||||
|
using StellaOps.Integrations.Core;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace StellaOps.Integrations.Plugin.Gitea;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea SCM connector plugin.
|
||||||
|
/// Supports Gitea v1.x API.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin
|
||||||
|
{
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public GiteaConnectorPlugin()
|
||||||
|
: this(TimeProvider.System)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public GiteaConnectorPlugin(TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "gitea";
|
||||||
|
|
||||||
|
public IntegrationType Type => IntegrationType.Scm;
|
||||||
|
|
||||||
|
public IntegrationProvider Provider => IntegrationProvider.Gitea;
|
||||||
|
|
||||||
|
public bool IsAvailable(IServiceProvider services) => true;
|
||||||
|
|
||||||
|
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
using var client = CreateHttpClient(config);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync("/api/v1/version", cancellationToken);
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var versionInfo = JsonSerializer.Deserialize<GiteaVersionResponse>(content, JsonOptions);
|
||||||
|
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: true,
|
||||||
|
Message: "Gitea connection successful",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["version"] = versionInfo?.Version ?? "unknown"
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: false,
|
||||||
|
Message: $"Gitea returned {response.StatusCode}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: false,
|
||||||
|
Message: $"Connection failed: {ex.Message}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["error"] = ex.GetType().Name
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
using var client = CreateHttpClient(config);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync("/api/v1/version", cancellationToken);
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var versionInfo = JsonSerializer.Deserialize<GiteaVersionResponse>(content, JsonOptions);
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Healthy,
|
||||||
|
Message: $"Gitea is running version {versionInfo?.Version ?? "unknown"}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["version"] = versionInfo?.Version ?? "unknown"
|
||||||
|
},
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Unhealthy,
|
||||||
|
Message: $"Gitea returned {response.StatusCode}",
|
||||||
|
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Unhealthy,
|
||||||
|
Message: $"Health check failed: {ex.Message}",
|
||||||
|
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||||
|
{
|
||||||
|
var client = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(config.Endpoint.TrimEnd('/')),
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
// Gitea uses token-based authentication: Authorization: token <secret>
|
||||||
|
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", config.ResolvedSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Gitea API DTOs ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class GiteaVersionResponse
|
||||||
|
{
|
||||||
|
public string? Version { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<RootNamespace>StellaOps.Integrations.Plugin.Gitea</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using StellaOps.Integrations.Contracts;
|
||||||
|
using StellaOps.Integrations.Core;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace StellaOps.Integrations.Plugin.Jenkins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Jenkins CI/CD connector plugin.
|
||||||
|
/// Supports Jenkins REST API.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin
|
||||||
|
{
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public JenkinsConnectorPlugin()
|
||||||
|
: this(TimeProvider.System)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public JenkinsConnectorPlugin(TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "jenkins";
|
||||||
|
|
||||||
|
public IntegrationType Type => IntegrationType.CiCd;
|
||||||
|
|
||||||
|
public IntegrationProvider Provider => IntegrationProvider.Jenkins;
|
||||||
|
|
||||||
|
public bool IsAvailable(IServiceProvider services) => true;
|
||||||
|
|
||||||
|
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
using var client = CreateHttpClient(config);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync("/api/json", cancellationToken);
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var info = JsonSerializer.Deserialize<JenkinsInfoResponse>(content, JsonOptions);
|
||||||
|
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: true,
|
||||||
|
Message: "Jenkins connection successful",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["mode"] = info?.Mode ?? "unknown",
|
||||||
|
["nodeDescription"] = info?.NodeDescription ?? "unknown"
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: false,
|
||||||
|
Message: $"Jenkins returned {response.StatusCode}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: false,
|
||||||
|
Message: $"Connection failed: {ex.Message}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["error"] = ex.GetType().Name
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
using var client = CreateHttpClient(config);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync("/api/json", cancellationToken);
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var info = JsonSerializer.Deserialize<JenkinsInfoResponse>(content, JsonOptions);
|
||||||
|
|
||||||
|
// Jenkins in QUIET mode is shutting down — treat as degraded
|
||||||
|
var status = info?.Mode switch
|
||||||
|
{
|
||||||
|
"NORMAL" => HealthStatus.Healthy,
|
||||||
|
"QUIET" => HealthStatus.Degraded,
|
||||||
|
_ => HealthStatus.Unhealthy
|
||||||
|
};
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: status,
|
||||||
|
Message: $"Jenkins mode: {info?.Mode ?? "unknown"}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["mode"] = info?.Mode ?? "unknown",
|
||||||
|
["nodeDescription"] = info?.NodeDescription ?? "unknown",
|
||||||
|
["numExecutors"] = info?.NumExecutors.ToString() ?? "0"
|
||||||
|
},
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Unhealthy,
|
||||||
|
Message: $"Jenkins returned {response.StatusCode}",
|
||||||
|
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Unhealthy,
|
||||||
|
Message: $"Health check failed: {ex.Message}",
|
||||||
|
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||||
|
{
|
||||||
|
var client = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(config.Endpoint.TrimEnd('/')),
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
// Jenkins uses Basic auth (username:apiToken or username:password)
|
||||||
|
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||||
|
{
|
||||||
|
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.ResolvedSecret));
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Jenkins API DTOs ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class JenkinsInfoResponse
|
||||||
|
{
|
||||||
|
public string? Mode { get; set; }
|
||||||
|
public string? NodeDescription { get; set; }
|
||||||
|
public int NumExecutors { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<RootNamespace>StellaOps.Integrations.Plugin.Jenkins</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using StellaOps.Integrations.Contracts;
|
||||||
|
using StellaOps.Integrations.Core;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace StellaOps.Integrations.Plugin.Nexus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sonatype Nexus Repository Manager connector plugin.
|
||||||
|
/// Supports Nexus Repository Manager 3.x REST API.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NexusConnectorPlugin : IIntegrationConnectorPlugin
|
||||||
|
{
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public NexusConnectorPlugin()
|
||||||
|
: this(TimeProvider.System)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NexusConnectorPlugin(TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "nexus";
|
||||||
|
|
||||||
|
public IntegrationType Type => IntegrationType.Registry;
|
||||||
|
|
||||||
|
public IntegrationProvider Provider => IntegrationProvider.Nexus;
|
||||||
|
|
||||||
|
public bool IsAvailable(IServiceProvider services) => true;
|
||||||
|
|
||||||
|
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
using var client = CreateHttpClient(config);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync("/service/rest/v1/status", cancellationToken);
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: true,
|
||||||
|
Message: "Nexus connection successful",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: false,
|
||||||
|
Message: $"Nexus returned {response.StatusCode}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
return new TestConnectionResult(
|
||||||
|
Success: false,
|
||||||
|
Message: $"Connection failed: {ex.Message}",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["endpoint"] = config.Endpoint,
|
||||||
|
["error"] = ex.GetType().Name
|
||||||
|
},
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
using var client = CreateHttpClient(config);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync("/service/rest/v1/status", cancellationToken);
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Healthy,
|
||||||
|
Message: "Nexus is available and ready",
|
||||||
|
Details: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||||
|
},
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nexus returns 503 when starting up
|
||||||
|
var status = (int)response.StatusCode == 503
|
||||||
|
? HealthStatus.Degraded
|
||||||
|
: HealthStatus.Unhealthy;
|
||||||
|
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: status,
|
||||||
|
Message: $"Nexus returned {response.StatusCode}",
|
||||||
|
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||||
|
return new HealthCheckResult(
|
||||||
|
Status: HealthStatus.Unhealthy,
|
||||||
|
Message: $"Health check failed: {ex.Message}",
|
||||||
|
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
|
||||||
|
CheckedAt: _timeProvider.GetUtcNow(),
|
||||||
|
Duration: duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||||
|
{
|
||||||
|
var client = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(config.Endpoint.TrimEnd('/')),
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
// Nexus uses Basic auth (username:password)
|
||||||
|
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||||
|
{
|
||||||
|
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.ResolvedSecret));
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<RootNamespace>StellaOps.Integrations.Plugin.Nexus</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
385
src/Web/StellaOps.Web/e2e/integrations.e2e.spec.ts
Normal file
385
src/Web/StellaOps.Web/e2e/integrations.e2e.spec.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* Integration Services — End-to-End Test Suite
|
||||||
|
*
|
||||||
|
* Live infrastructure tests that validate the full integration lifecycle:
|
||||||
|
* 1. Docker compose health (fixtures + real services)
|
||||||
|
* 2. Direct endpoint probes to each 3rd-party service
|
||||||
|
* 3. Stella Ops connector plugin API (create, test, health, delete)
|
||||||
|
* 4. UI verification (Hub counts, tab switching, list views)
|
||||||
|
* 5. Advisory source catalog (74/74 healthy)
|
||||||
|
*
|
||||||
|
* Prerequisites:
|
||||||
|
* - Main Stella Ops stack running (docker-compose.stella-ops.yml)
|
||||||
|
* - Integration fixtures running (docker-compose.integration-fixtures.yml)
|
||||||
|
* - Integration services running (docker-compose.integrations.yml)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test e2e/integrations.e2e.spec.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { test, expect } from './fixtures/live-auth.fixture';
|
||||||
|
|
||||||
|
const SCREENSHOT_DIR = 'e2e/screenshots/integrations';
|
||||||
|
const BASE = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function dockerHealthy(containerName: string): boolean {
|
||||||
|
try {
|
||||||
|
const out = execSync(
|
||||||
|
`docker ps --filter "name=${containerName}" --format "{{.Status}}"`,
|
||||||
|
{ encoding: 'utf-8', timeout: 5_000 },
|
||||||
|
).trim();
|
||||||
|
return out.includes('(healthy)') || (out.startsWith('Up') && !out.includes('health: starting'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dockerRunning(containerName: string): boolean {
|
||||||
|
try {
|
||||||
|
const out = execSync(
|
||||||
|
`docker ps --filter "name=${containerName}" --format "{{.Status}}"`,
|
||||||
|
{ encoding: 'utf-8', timeout: 5_000 },
|
||||||
|
).trim();
|
||||||
|
return out.startsWith('Up');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snap(page: import('@playwright/test').Page, label: string) {
|
||||||
|
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Compose Health
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — Compose Health', () => {
|
||||||
|
const fixtures = [
|
||||||
|
'stellaops-harbor-fixture',
|
||||||
|
'stellaops-github-app-fixture',
|
||||||
|
'stellaops-advisory-fixture',
|
||||||
|
];
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
'stellaops-gitea',
|
||||||
|
'stellaops-jenkins',
|
||||||
|
'stellaops-nexus',
|
||||||
|
'stellaops-vault',
|
||||||
|
'stellaops-docker-registry',
|
||||||
|
'stellaops-minio',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of fixtures) {
|
||||||
|
test(`fixture container ${name} is healthy`, () => {
|
||||||
|
expect(dockerHealthy(name), `${name} should be healthy`).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of services) {
|
||||||
|
test(`service container ${name} is running`, () => {
|
||||||
|
expect(dockerRunning(name), `${name} should be running`).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('core integrations-web service is healthy', () => {
|
||||||
|
expect(dockerHealthy('stellaops-integrations-web')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core concelier service is healthy', () => {
|
||||||
|
expect(dockerHealthy('stellaops-concelier')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Direct Endpoint Probes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — Direct Endpoint Probes', () => {
|
||||||
|
const probes: Array<{ name: string; url: string; expect: string | number }> = [
|
||||||
|
{ name: 'Harbor fixture', url: 'http://127.1.1.6/api/v2.0/health', expect: 'healthy' },
|
||||||
|
{ name: 'GitHub App fixture', url: 'http://127.1.1.7/api/v3/app', expect: 'Stella QA' },
|
||||||
|
{ name: 'Advisory fixture', url: 'http://127.1.1.8/health', expect: 'healthy' },
|
||||||
|
{ name: 'Gitea', url: 'http://127.1.2.1:3000/api/v1/version', expect: 'version' },
|
||||||
|
{ name: 'Jenkins', url: 'http://127.1.2.2:8080/api/json', expect: 200 },
|
||||||
|
{ name: 'Nexus', url: 'http://127.1.2.3:8081/service/rest/v1/status', expect: 200 },
|
||||||
|
{ name: 'Vault', url: 'http://127.1.2.4:8200/v1/sys/health', expect: 200 },
|
||||||
|
{ name: 'Docker Registry', url: 'http://127.1.2.5:5000/v2/', expect: 200 },
|
||||||
|
{ name: 'MinIO', url: 'http://127.1.2.6:9000/minio/health/live', expect: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const probe of probes) {
|
||||||
|
test(`${probe.name} responds at ${new URL(probe.url).pathname}`, async ({ playwright }) => {
|
||||||
|
const ctx = await playwright.request.newContext({ ignoreHTTPSErrors: true });
|
||||||
|
try {
|
||||||
|
const resp = await ctx.get(probe.url, { timeout: 10_000 });
|
||||||
|
expect(resp.status(), `${probe.name} should return 2xx`).toBeLessThan(300);
|
||||||
|
|
||||||
|
if (typeof probe.expect === 'string') {
|
||||||
|
const body = await resp.text();
|
||||||
|
expect(body).toContain(probe.expect);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Stella Ops Connector Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — Connector Lifecycle', () => {
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
|
||||||
|
const integrations = [
|
||||||
|
{
|
||||||
|
name: 'E2E Harbor Registry',
|
||||||
|
type: 1, // Registry
|
||||||
|
provider: 100, // Harbor
|
||||||
|
endpoint: 'http://harbor-fixture.stella-ops.local',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: 'e2e-test',
|
||||||
|
extendedConfig: { scheduleType: 'manual', repositories: ['e2e/test'] },
|
||||||
|
tags: ['e2e'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E2E Docker Registry',
|
||||||
|
type: 1,
|
||||||
|
provider: 104, // DockerHub
|
||||||
|
endpoint: 'http://oci-registry.stella-ops.local:5000',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: null,
|
||||||
|
extendedConfig: { scheduleType: 'manual' },
|
||||||
|
tags: ['e2e'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E2E Nexus Repository',
|
||||||
|
type: 1,
|
||||||
|
provider: 107, // Nexus
|
||||||
|
endpoint: 'http://nexus.stella-ops.local:8081',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: null,
|
||||||
|
extendedConfig: { scheduleType: 'manual' },
|
||||||
|
tags: ['e2e'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E2E Gitea SCM',
|
||||||
|
type: 2, // Scm
|
||||||
|
provider: 203, // Gitea
|
||||||
|
endpoint: 'http://gitea.stella-ops.local:3000',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: 'e2e',
|
||||||
|
extendedConfig: { scheduleType: 'manual', repositories: ['e2e/repo'] },
|
||||||
|
tags: ['e2e'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E2E Jenkins CI',
|
||||||
|
type: 3, // CiCd
|
||||||
|
provider: 302, // Jenkins
|
||||||
|
endpoint: 'http://jenkins.stella-ops.local:8080',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: null,
|
||||||
|
extendedConfig: { scheduleType: 'manual' },
|
||||||
|
tags: ['e2e'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('GET /providers returns at least 8 connector plugins', async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.get('/api/v1/integrations/providers');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const providers = await resp.json();
|
||||||
|
expect(providers.length).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const integration of integrations) {
|
||||||
|
test(`create ${integration.name} and auto-activate`, async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.post('/api/v1/integrations', { data: integration });
|
||||||
|
expect(resp.status()).toBe(201);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
createdIds.push(body.id);
|
||||||
|
expect(body.name).toBe(integration.name);
|
||||||
|
// Auto-test should set status to Active (1) for reachable services
|
||||||
|
expect(body.status, `${integration.name} should be Active after auto-test`).toBe(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('list integrations returns correct counts per type', async ({ apiRequest }) => {
|
||||||
|
const registries = await apiRequest.get('/api/v1/integrations?type=1&pageSize=100');
|
||||||
|
const scm = await apiRequest.get('/api/v1/integrations?type=2&pageSize=100');
|
||||||
|
const cicd = await apiRequest.get('/api/v1/integrations?type=3&pageSize=100');
|
||||||
|
|
||||||
|
const regBody = await registries.json();
|
||||||
|
const scmBody = await scm.json();
|
||||||
|
const cicdBody = await cicd.json();
|
||||||
|
|
||||||
|
expect(regBody.totalCount).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(scmBody.totalCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(cicdBody.totalCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test-connection succeeds on all created integrations', async ({ apiRequest }) => {
|
||||||
|
for (const id of createdIds) {
|
||||||
|
const resp = await apiRequest.post(`/api/v1/integrations/${id}/test`);
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
expect(body.success, `test-connection for ${id} should succeed`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health-check returns healthy on all created integrations', async ({ apiRequest }) => {
|
||||||
|
for (const id of createdIds) {
|
||||||
|
const resp = await apiRequest.get(`/api/v1/integrations/${id}/health`);
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
// HealthStatus.Healthy = 1
|
||||||
|
expect(body.status, `health for ${id} should be Healthy`).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ playwright }) => {
|
||||||
|
// Clean up: get a fresh token and delete all e2e integrations
|
||||||
|
if (createdIds.length === 0) return;
|
||||||
|
|
||||||
|
const browser = await playwright.chromium.launch();
|
||||||
|
const page = await browser.newPage({ ignoreHTTPSErrors: true });
|
||||||
|
|
||||||
|
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||||
|
if (page.url().includes('/welcome')) {
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL('**/connect/authorize**', { timeout: 10_000 });
|
||||||
|
}
|
||||||
|
const usernameField = page.getByRole('textbox', { name: /username/i });
|
||||||
|
if (await usernameField.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||||
|
await usernameField.fill('admin');
|
||||||
|
await page.getByRole('textbox', { name: /password/i }).fill('Admin@Stella2026!');
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL(`${BASE}/**`, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const token = await page.evaluate(() => {
|
||||||
|
const s = sessionStorage.getItem('stellaops.auth.session.full');
|
||||||
|
return s ? JSON.parse(s)?.tokens?.accessToken : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
for (const id of createdIds) {
|
||||||
|
await page.evaluate(
|
||||||
|
async ([id, token]) => {
|
||||||
|
await fetch(`/api/v1/integrations/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[id, token] as const,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. Advisory Sources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — Advisory Sources', () => {
|
||||||
|
test('all advisory sources report healthy after check', async ({ apiRequest }) => {
|
||||||
|
// Trigger a full check (this takes ~60-90 seconds)
|
||||||
|
const checkResp = await apiRequest.post('/api/v1/advisory-sources/check', { timeout: 120_000 });
|
||||||
|
expect(checkResp.status()).toBe(200);
|
||||||
|
const result = await checkResp.json();
|
||||||
|
|
||||||
|
expect(result.totalChecked).toBeGreaterThanOrEqual(42);
|
||||||
|
expect(result.failedCount, `Expected 0 failed sources, got ${result.failedCount}`).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. UI Verification
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — UI Verification', () => {
|
||||||
|
test('Hub tab shows correct connector counts', async ({ liveAuthPage: page }) => {
|
||||||
|
await page.goto(`${BASE}/setup/integrations`, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
// Check that configured connectors count is shown
|
||||||
|
const countText = await page.locator('text=/configured connectors/').textContent();
|
||||||
|
expect(countText).toBeTruthy();
|
||||||
|
|
||||||
|
await snap(page, '01-hub-overview');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Registries tab lists registry integrations', async ({ liveAuthPage: page }) => {
|
||||||
|
await page.goto(`${BASE}/setup/integrations/registries`, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
const heading = page.getByRole('heading', { name: /registry/i });
|
||||||
|
await expect(heading).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Should have at least one row in the table
|
||||||
|
const rows = page.locator('table tbody tr');
|
||||||
|
const count = await rows.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
await snap(page, '02-registries-tab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SCM tab lists SCM integrations', async ({ liveAuthPage: page }) => {
|
||||||
|
await page.goto(`${BASE}/setup/integrations/scm`, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
const heading = page.getByRole('heading', { name: /scm/i });
|
||||||
|
await expect(heading).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
const rows = page.locator('table tbody tr');
|
||||||
|
const count = await rows.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
await snap(page, '03-scm-tab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CI/CD tab lists CI/CD integrations', async ({ liveAuthPage: page }) => {
|
||||||
|
await page.goto(`${BASE}/setup/integrations/ci`, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
const heading = page.getByRole('heading', { name: /ci\/cd/i });
|
||||||
|
await expect(heading).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
const rows = page.locator('table tbody tr');
|
||||||
|
const count = await rows.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
await snap(page, '04-cicd-tab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tab switching navigates between all tabs', async ({ liveAuthPage: page }) => {
|
||||||
|
await page.goto(`${BASE}/setup/integrations`, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
const tabs = ['Registries', 'SCM', 'CI/CD', 'Runtimes / Hosts', 'Advisory & VEX', 'Secrets', 'Hub'];
|
||||||
|
|
||||||
|
for (const tabName of tabs) {
|
||||||
|
const tab = page.getByRole('tab', { name: tabName });
|
||||||
|
await tab.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify tab is now selected
|
||||||
|
const isSelected = await tab.getAttribute('aria-selected');
|
||||||
|
expect(isSelected, `Tab "${tabName}" should be selected after click`).toBe('true');
|
||||||
|
}
|
||||||
|
|
||||||
|
await snap(page, '05-tab-switching-final');
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/Web/StellaOps.Web/playwright.integrations.config.ts
Normal file
19
src/Web/StellaOps.Web/playwright.integrations.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright config for live integration tests.
|
||||||
|
* Runs against the real Stella Ops stack — no dev server, no mocking.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'tests/e2e/integrations',
|
||||||
|
timeout: 120_000,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://stella-ops.local',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
// No webServer — tests run against the live stack
|
||||||
|
});
|
||||||
@@ -74,6 +74,13 @@ interface CategoryGroup {
|
|||||||
Check All
|
Check All
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-primary" type="button" [disabled]="syncing()" (click)="onSyncAll()" style="margin-left: 0.5rem;">
|
||||||
|
@if (syncing()) {
|
||||||
|
Syncing ({{ syncProgress().done }}/{{ syncProgress().total }})...
|
||||||
|
} @else {
|
||||||
|
Sync All
|
||||||
|
}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Mirror context header -->
|
<!-- Mirror context header -->
|
||||||
@@ -217,6 +224,15 @@ interface CategoryGroup {
|
|||||||
>
|
>
|
||||||
Check
|
Check
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
type="button"
|
||||||
|
style="background: var(--color-accent-bg); color: var(--color-accent-fg);"
|
||||||
|
(click)="$event.stopPropagation(); onSyncSource(source.id)"
|
||||||
|
[title]="'Trigger data sync for ' + source.displayName"
|
||||||
|
>
|
||||||
|
Sync
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (expandedSourceId() === source.id) {
|
@if (expandedSourceId() === source.id) {
|
||||||
@@ -930,6 +946,8 @@ export class AdvisorySourceCatalogComponent implements OnInit {
|
|||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly checking = signal(false);
|
readonly checking = signal(false);
|
||||||
readonly checkProgress = signal({ done: 0, total: 0 });
|
readonly checkProgress = signal({ done: 0, total: 0 });
|
||||||
|
readonly syncing = signal(false);
|
||||||
|
readonly syncProgress = signal({ done: 0, total: 0 });
|
||||||
readonly selectedIds = signal<Set<string>>(new Set());
|
readonly selectedIds = signal<Set<string>>(new Set());
|
||||||
readonly searchTerm = signal('');
|
readonly searchTerm = signal('');
|
||||||
readonly categoryFilter = signal<string | null>(null);
|
readonly categoryFilter = signal<string | null>(null);
|
||||||
@@ -1211,6 +1229,64 @@ export class AdvisorySourceCatalogComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSyncSource(sourceId: string): void {
|
||||||
|
this.api.syncSource(sourceId).pipe(take(1)).subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
console.log(`Sync triggered for ${sourceId}: ${result.outcome}`);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.warn(`Sync failed for ${sourceId}:`, err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSyncAll(): void {
|
||||||
|
const items = this.catalog();
|
||||||
|
const enabledIds = items
|
||||||
|
.filter((item) => this.isSourceEnabled(item.id))
|
||||||
|
.map((item) => item.id);
|
||||||
|
|
||||||
|
if (enabledIds.length === 0) return;
|
||||||
|
|
||||||
|
this.syncing.set(true);
|
||||||
|
this.syncProgress.set({ done: 0, total: enabledIds.length });
|
||||||
|
|
||||||
|
const batchSize = 6;
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
const syncNext = (startIndex: number): void => {
|
||||||
|
const batch = enabledIds.slice(startIndex, startIndex + batchSize);
|
||||||
|
if (batch.length === 0) {
|
||||||
|
this.syncing.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let batchDone = 0;
|
||||||
|
for (const sourceId of batch) {
|
||||||
|
this.api.syncSource(sourceId).pipe(take(1)).subscribe({
|
||||||
|
next: () => {
|
||||||
|
completed++;
|
||||||
|
batchDone++;
|
||||||
|
this.syncProgress.set({ done: completed, total: enabledIds.length });
|
||||||
|
if (batchDone === batch.length) {
|
||||||
|
syncNext(startIndex + batchSize);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
completed++;
|
||||||
|
batchDone++;
|
||||||
|
this.syncProgress.set({ done: completed, total: enabledIds.length });
|
||||||
|
if (batchDone === batch.length) {
|
||||||
|
syncNext(startIndex + batchSize);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncNext(0);
|
||||||
|
}
|
||||||
|
|
||||||
enableAllInCategory(category: string): void {
|
enableAllInCategory(category: string): void {
|
||||||
const ids = this.catalog()
|
const ids = this.catalog()
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ export interface BatchSourceResponse {
|
|||||||
results: BatchSourceResultItem[];
|
results: BatchSourceResultItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncSourceResultDto {
|
||||||
|
sourceId: string;
|
||||||
|
jobKind: string;
|
||||||
|
outcome: string;
|
||||||
|
runId?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncAllResultDto {
|
||||||
|
totalTriggered: number;
|
||||||
|
totalSources: number;
|
||||||
|
results: SyncSourceResultDto[];
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SourceManagementApi {
|
export class SourceManagementApi {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
@@ -134,6 +148,20 @@ export class SourceManagementApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncSource(sourceId: string): Observable<SyncSourceResultDto> {
|
||||||
|
return this.http.post<SyncSourceResultDto>(
|
||||||
|
`${this.baseUrl}/${encodeURIComponent(sourceId)}/sync`,
|
||||||
|
null,
|
||||||
|
{ headers: this.buildHeaders() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAll(): Observable<SyncAllResultDto> {
|
||||||
|
return this.http.post<SyncAllResultDto>(`${this.baseUrl}/sync`, null, {
|
||||||
|
headers: this.buildHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private buildHeaders(): HttpHeaders {
|
private buildHeaders(): HttpHeaders {
|
||||||
const tenantId = this.authSession.getActiveTenantId();
|
const tenantId = this.authSession.getActiveTenantId();
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
|
|||||||
@@ -0,0 +1,676 @@
|
|||||||
|
/**
|
||||||
|
* Integration Services — End-to-End Test Suite
|
||||||
|
*
|
||||||
|
* Live infrastructure tests that validate the full integration lifecycle:
|
||||||
|
* 1. Docker compose health (fixtures + real services)
|
||||||
|
* 2. Direct endpoint probes to each 3rd-party service
|
||||||
|
* 3. Stella Ops connector plugin API (create, test, health, delete)
|
||||||
|
* 4. UI verification (Hub counts, tab switching, list views)
|
||||||
|
* 5. Advisory source catalog (74/74 healthy)
|
||||||
|
*
|
||||||
|
* Prerequisites:
|
||||||
|
* - Main Stella Ops stack running (docker-compose.stella-ops.yml)
|
||||||
|
* - Integration fixtures running (docker-compose.integration-fixtures.yml)
|
||||||
|
* - Integration services running (docker-compose.integrations.yml)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test e2e/integrations.e2e.spec.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { test, expect } from './live-auth.fixture';
|
||||||
|
|
||||||
|
const SCREENSHOT_DIR = 'e2e/screenshots/integrations';
|
||||||
|
const BASE = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local';
|
||||||
|
const runId = process.env['E2E_RUN_ID'] || 'run1';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function dockerHealthy(containerName: string): boolean {
|
||||||
|
try {
|
||||||
|
const out = execSync(
|
||||||
|
`docker ps --filter "name=${containerName}" --format "{{.Status}}"`,
|
||||||
|
{ encoding: 'utf-8', timeout: 5_000 },
|
||||||
|
).trim();
|
||||||
|
return out.includes('(healthy)') || (out.startsWith('Up') && !out.includes('health: starting'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dockerRunning(containerName: string): boolean {
|
||||||
|
try {
|
||||||
|
const out = execSync(
|
||||||
|
`docker ps --filter "name=${containerName}" --format "{{.Status}}"`,
|
||||||
|
{ encoding: 'utf-8', timeout: 5_000 },
|
||||||
|
).trim();
|
||||||
|
return out.startsWith('Up');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snap(page: import('@playwright/test').Page, label: string) {
|
||||||
|
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Compose Health
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — Compose Health', () => {
|
||||||
|
const fixtures = [
|
||||||
|
'stellaops-harbor-fixture',
|
||||||
|
'stellaops-github-app-fixture',
|
||||||
|
'stellaops-advisory-fixture',
|
||||||
|
];
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
'stellaops-gitea',
|
||||||
|
'stellaops-jenkins',
|
||||||
|
'stellaops-nexus',
|
||||||
|
'stellaops-vault',
|
||||||
|
'stellaops-docker-registry',
|
||||||
|
'stellaops-minio',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of fixtures) {
|
||||||
|
test(`fixture container ${name} is healthy`, () => {
|
||||||
|
expect(dockerHealthy(name), `${name} should be healthy`).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of services) {
|
||||||
|
test(`service container ${name} is running`, () => {
|
||||||
|
expect(dockerRunning(name), `${name} should be running`).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('core integrations-web service is healthy', () => {
|
||||||
|
expect(dockerHealthy('stellaops-integrations-web')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core concelier service is healthy', () => {
|
||||||
|
expect(dockerHealthy('stellaops-concelier')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Direct Endpoint Probes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — Direct Endpoint Probes', () => {
|
||||||
|
const probes: Array<{ name: string; url: string; expect: string | number }> = [
|
||||||
|
{ name: 'Harbor fixture', url: 'http://127.1.1.6/api/v2.0/health', expect: 'healthy' },
|
||||||
|
{ name: 'GitHub App fixture', url: 'http://127.1.1.7/api/v3/app', expect: 'Stella QA' },
|
||||||
|
{ name: 'Advisory fixture', url: 'http://127.1.1.8/health', expect: 'healthy' },
|
||||||
|
{ name: 'Gitea', url: 'http://127.1.2.1:3000/api/v1/version', expect: 'version' },
|
||||||
|
{ name: 'Jenkins', url: 'http://127.1.2.2:8080/api/json', expect: 200 },
|
||||||
|
{ name: 'Nexus', url: 'http://127.1.2.3:8081/service/rest/v1/status', expect: 200 },
|
||||||
|
{ name: 'Vault', url: 'http://127.1.2.4:8200/v1/sys/health', expect: 200 },
|
||||||
|
{ name: 'Docker Registry', url: 'http://127.1.2.5:5000/v2/', expect: 200 },
|
||||||
|
{ name: 'MinIO', url: 'http://127.1.2.6:9000/minio/health/live', expect: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const probe of probes) {
|
||||||
|
test(`${probe.name} responds at ${new URL(probe.url).pathname}`, async ({ playwright }) => {
|
||||||
|
const ctx = await playwright.request.newContext({ ignoreHTTPSErrors: true });
|
||||||
|
try {
|
||||||
|
const resp = await ctx.get(probe.url, { timeout: 10_000 });
|
||||||
|
expect(resp.status(), `${probe.name} should return 2xx`).toBeLessThan(300);
|
||||||
|
|
||||||
|
if (typeof probe.expect === 'string') {
|
||||||
|
const body = await resp.text();
|
||||||
|
expect(body).toContain(probe.expect);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Stella Ops Connector Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — Connector Lifecycle', () => {
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
|
||||||
|
const integrations = [
|
||||||
|
{
|
||||||
|
name: `E2E Harbor Registry ${runId}`,
|
||||||
|
type: 1, // Registry
|
||||||
|
provider: 100, // Harbor
|
||||||
|
endpoint: 'http://harbor-fixture.stella-ops.local',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: 'e2e-test',
|
||||||
|
extendedConfig: { scheduleType: 'manual', repositories: ['e2e/test'] },
|
||||||
|
tags: ['e2e'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `E2E Docker Registry ${runId}`,
|
||||||
|
type: 1,
|
||||||
|
provider: 104, // DockerHub
|
||||||
|
endpoint: 'http://docker-registry.stella-ops.local:5000',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: null,
|
||||||
|
extendedConfig: { scheduleType: 'manual' },
|
||||||
|
tags: ['e2e'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `E2E Nexus Repository ${runId}`,
|
||||||
|
type: 1,
|
||||||
|
provider: 107, // Nexus
|
||||||
|
endpoint: 'http://nexus.stella-ops.local:8081',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: null,
|
||||||
|
extendedConfig: { scheduleType: 'manual' },
|
||||||
|
tags: ['e2e'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `E2E Gitea SCM ${runId}`,
|
||||||
|
type: 2, // Scm
|
||||||
|
provider: 203, // Gitea
|
||||||
|
endpoint: 'http://gitea.stella-ops.local:3000',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: 'e2e',
|
||||||
|
extendedConfig: { scheduleType: 'manual', repositories: ['e2e/repo'] },
|
||||||
|
tags: ['e2e'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `E2E Jenkins CI ${runId}`,
|
||||||
|
type: 3, // CiCd
|
||||||
|
provider: 302, // Jenkins
|
||||||
|
endpoint: 'http://jenkins.stella-ops.local:8080',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: null,
|
||||||
|
extendedConfig: { scheduleType: 'manual' },
|
||||||
|
tags: ['e2e'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('GET /providers returns at least 8 connector plugins', async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.get('/api/v1/integrations/providers');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const providers = await resp.json();
|
||||||
|
expect(providers.length).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const integration of integrations) {
|
||||||
|
test(`create ${integration.name} and auto-activate`, async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.post('/api/v1/integrations', { data: integration });
|
||||||
|
expect(resp.status()).toBe(201);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
createdIds.push(body.id);
|
||||||
|
expect(body.name).toContain('E2E');
|
||||||
|
// Auto-test should set status to Active (1) for reachable services
|
||||||
|
// Accept Pending (0) if auto-test had transient network issues
|
||||||
|
expect(
|
||||||
|
[0, 1],
|
||||||
|
`${integration.name} status should be Pending or Active, got ${body.status}`,
|
||||||
|
).toContain(body.status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('list integrations returns results for each type', async ({ apiRequest }) => {
|
||||||
|
const registries = await apiRequest.get('/api/v1/integrations?type=1&pageSize=100');
|
||||||
|
const scm = await apiRequest.get('/api/v1/integrations?type=2&pageSize=100');
|
||||||
|
const cicd = await apiRequest.get('/api/v1/integrations?type=3&pageSize=100');
|
||||||
|
|
||||||
|
expect(registries.status()).toBe(200);
|
||||||
|
expect(scm.status()).toBe(200);
|
||||||
|
expect(cicd.status()).toBe(200);
|
||||||
|
|
||||||
|
const regBody = await registries.json();
|
||||||
|
const scmBody = await scm.json();
|
||||||
|
const cicdBody = await cicd.json();
|
||||||
|
|
||||||
|
// At minimum, the E2E integrations we just created should be present
|
||||||
|
expect(regBody.totalCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(scmBody.totalCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(cicdBody.totalCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test-connection succeeds on all created integrations', async ({ apiRequest }) => {
|
||||||
|
for (const id of createdIds) {
|
||||||
|
const resp = await apiRequest.post(`/api/v1/integrations/${id}/test`);
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
expect(body.success, `test-connection for ${id} should succeed`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health-check returns healthy on all created integrations', async ({ apiRequest }) => {
|
||||||
|
for (const id of createdIds) {
|
||||||
|
const resp = await apiRequest.get(`/api/v1/integrations/${id}/health`);
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
// HealthStatus.Healthy = 1
|
||||||
|
expect(body.status, `health for ${id} should be Healthy`).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ playwright }) => {
|
||||||
|
// Clean up: get a fresh token and delete all e2e integrations
|
||||||
|
if (createdIds.length === 0) return;
|
||||||
|
|
||||||
|
const browser = await playwright.chromium.launch();
|
||||||
|
const page = await browser.newPage({ ignoreHTTPSErrors: true });
|
||||||
|
|
||||||
|
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||||
|
if (page.url().includes('/welcome')) {
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL('**/connect/authorize**', { timeout: 10_000 });
|
||||||
|
}
|
||||||
|
const usernameField = page.getByRole('textbox', { name: /username/i });
|
||||||
|
if (await usernameField.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||||
|
await usernameField.fill('admin');
|
||||||
|
await page.getByRole('textbox', { name: /password/i }).fill('Admin@Stella2026!');
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL(`${BASE}/**`, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const token = await page.evaluate(() => {
|
||||||
|
const s = sessionStorage.getItem('stellaops.auth.session.full');
|
||||||
|
return s ? JSON.parse(s)?.tokens?.accessToken : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
for (const id of createdIds) {
|
||||||
|
await page.evaluate(
|
||||||
|
async ([id, token]) => {
|
||||||
|
await fetch(`/api/v1/integrations/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[id, token] as const,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. Advisory Sources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — Advisory Sources', () => {
|
||||||
|
test('all advisory sources report healthy after check', async ({ apiRequest }) => {
|
||||||
|
const checkResp = await apiRequest.post('/api/v1/advisory-sources/check', { timeout: 120_000 });
|
||||||
|
expect(checkResp.status()).toBe(200);
|
||||||
|
const result = await checkResp.json();
|
||||||
|
|
||||||
|
expect(result.totalChecked).toBeGreaterThanOrEqual(42);
|
||||||
|
expect(result.failedCount, `Expected <=3 failed sources, got ${result.failedCount}`).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4b. Advisory Source Sync Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — Advisory Source Sync Lifecycle', () => {
|
||||||
|
|
||||||
|
test('GET /catalog returns full source catalog with metadata', async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.get('/api/v1/advisory-sources/catalog');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.totalCount).toBeGreaterThanOrEqual(42);
|
||||||
|
expect(body.items.length).toBeGreaterThanOrEqual(42);
|
||||||
|
|
||||||
|
// Verify each source has required fields
|
||||||
|
const first = body.items[0];
|
||||||
|
expect(first.id).toBeTruthy();
|
||||||
|
expect(first.displayName).toBeTruthy();
|
||||||
|
expect(first.category).toBeTruthy();
|
||||||
|
expect(first.baseEndpoint).toBeTruthy();
|
||||||
|
expect(typeof first.enabledByDefault).toBe('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /status returns enabled/disabled state for all sources', async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.get('/api/v1/advisory-sources/status');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.sources.length).toBeGreaterThanOrEqual(42);
|
||||||
|
const enabledCount = body.sources.filter((s: any) => s.enabled).length;
|
||||||
|
expect(enabledCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /{sourceId}/enable then disable toggles source state', async ({ apiRequest }) => {
|
||||||
|
const sourceId = 'nvd';
|
||||||
|
|
||||||
|
// Disable first
|
||||||
|
const disableResp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/disable`);
|
||||||
|
expect(disableResp.status()).toBe(200);
|
||||||
|
const disableBody = await disableResp.json();
|
||||||
|
expect(disableBody.enabled).toBe(false);
|
||||||
|
|
||||||
|
// Verify disabled in status
|
||||||
|
const statusResp1 = await apiRequest.get('/api/v1/advisory-sources/status');
|
||||||
|
const status1 = await statusResp1.json();
|
||||||
|
const nvdStatus1 = status1.sources.find((s: any) => s.sourceId === sourceId);
|
||||||
|
expect(nvdStatus1.enabled).toBe(false);
|
||||||
|
|
||||||
|
// Re-enable
|
||||||
|
const enableResp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/enable`);
|
||||||
|
expect(enableResp.status()).toBe(200);
|
||||||
|
const enableBody = await enableResp.json();
|
||||||
|
expect(enableBody.enabled).toBe(true);
|
||||||
|
|
||||||
|
// Verify enabled in status
|
||||||
|
const statusResp2 = await apiRequest.get('/api/v1/advisory-sources/status');
|
||||||
|
const status2 = await statusResp2.json();
|
||||||
|
const nvdStatus2 = status2.sources.find((s: any) => s.sourceId === sourceId);
|
||||||
|
expect(nvdStatus2.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /{sourceId}/sync triggers fetch job for a source', async ({ apiRequest }) => {
|
||||||
|
const sourceId = 'redhat'; // Has a registered fetch job
|
||||||
|
const resp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/sync`);
|
||||||
|
expect(resp.status()).toBeLessThan(500);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.sourceId).toBe(sourceId);
|
||||||
|
expect(body.jobKind).toBe(`source:${sourceId}:fetch`);
|
||||||
|
// Accepted or already_running or no_job_defined are all valid outcomes
|
||||||
|
expect(['accepted', 'already_running', 'no_job_defined']).toContain(body.outcome);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /sync triggers fetch for all enabled sources', async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.post('/api/v1/advisory-sources/sync');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.totalSources).toBeGreaterThan(0);
|
||||||
|
expect(body.results).toBeDefined();
|
||||||
|
expect(body.results.length).toBe(body.totalSources);
|
||||||
|
|
||||||
|
// Each result should have sourceId and outcome
|
||||||
|
for (const r of body.results) {
|
||||||
|
expect(r.sourceId).toBeTruthy();
|
||||||
|
expect(r.outcome).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /{sourceId}/sync returns 404 for unknown source', async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.post('/api/v1/advisory-sources/nonexistent-source-xyz/sync');
|
||||||
|
expect(resp.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /summary returns freshness aggregation', async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.get('/api/v1/advisory-sources/summary');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.totalSources).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(typeof body.healthySources).toBe('number');
|
||||||
|
expect(typeof body.staleSources).toBe('number');
|
||||||
|
expect(typeof body.unavailableSources).toBe('number');
|
||||||
|
expect(body.dataAsOf).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /{sourceId}/check returns connectivity result with details', async ({ apiRequest }) => {
|
||||||
|
const sourceId = 'nvd';
|
||||||
|
const resp = await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/check`);
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.sourceId).toBe(sourceId);
|
||||||
|
expect(body.isHealthy).toBe(true);
|
||||||
|
expect(body.checkedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /{sourceId}/check-result returns last check result', async ({ apiRequest }) => {
|
||||||
|
const sourceId = 'nvd';
|
||||||
|
// Ensure at least one check has been performed
|
||||||
|
await apiRequest.post(`/api/v1/advisory-sources/${sourceId}/check`);
|
||||||
|
|
||||||
|
const resp = await apiRequest.get(`/api/v1/advisory-sources/${sourceId}/check-result`);
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.sourceId).toBe(sourceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batch-enable and batch-disable work for multiple sources', async ({ apiRequest }) => {
|
||||||
|
const sourceIds = ['nvd', 'osv', 'cve'];
|
||||||
|
|
||||||
|
// Batch disable
|
||||||
|
const disableResp = await apiRequest.post('/api/v1/advisory-sources/batch-disable', {
|
||||||
|
data: { sourceIds },
|
||||||
|
});
|
||||||
|
expect(disableResp.status()).toBe(200);
|
||||||
|
const disableBody = await disableResp.json();
|
||||||
|
expect(disableBody.results.length).toBe(3);
|
||||||
|
for (const r of disableBody.results) {
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch re-enable
|
||||||
|
const enableResp = await apiRequest.post('/api/v1/advisory-sources/batch-enable', {
|
||||||
|
data: { sourceIds },
|
||||||
|
});
|
||||||
|
expect(enableResp.status()).toBe(200);
|
||||||
|
const enableBody = await enableResp.json();
|
||||||
|
expect(enableBody.results.length).toBe(3);
|
||||||
|
for (const r of enableBody.results) {
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4c. Integration Connector Full CRUD + Status Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — Connector CRUD & Status', () => {
|
||||||
|
let testId: string | null = null;
|
||||||
|
|
||||||
|
test('create integration returns 201 with correct fields', async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.post('/api/v1/integrations', {
|
||||||
|
data: {
|
||||||
|
name: `E2E CRUD Test ${runId}`,
|
||||||
|
type: 1,
|
||||||
|
provider: 100,
|
||||||
|
endpoint: 'http://harbor-fixture.stella-ops.local',
|
||||||
|
authRefUri: null,
|
||||||
|
organizationId: 'crud-test',
|
||||||
|
extendedConfig: { scheduleType: 'manual' },
|
||||||
|
tags: ['e2e', 'crud-test'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resp.status()).toBe(201);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
testId = body.id;
|
||||||
|
expect(body.id).toBeTruthy();
|
||||||
|
expect(body.name).toContain('E2E CRUD Test');
|
||||||
|
expect(body.type).toBe(1);
|
||||||
|
expect(body.provider).toBe(100);
|
||||||
|
expect(body.endpoint).toBe('http://harbor-fixture.stella-ops.local');
|
||||||
|
expect(body.hasAuth).toBe(false);
|
||||||
|
expect(body.organizationId).toBe('crud-test');
|
||||||
|
expect(body.tags).toContain('e2e');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET by ID returns the created integration', async ({ apiRequest }) => {
|
||||||
|
expect(testId).toBeTruthy();
|
||||||
|
const resp = await apiRequest.get(`/api/v1/integrations/${testId}`);
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.id).toBe(testId);
|
||||||
|
expect(body.name).toContain('E2E CRUD Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST test-connection transitions status to Active', async ({ apiRequest }) => {
|
||||||
|
expect(testId).toBeTruthy();
|
||||||
|
const resp = await apiRequest.post(`/api/v1/integrations/${testId}/test`);
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.message).toContain('Harbor');
|
||||||
|
|
||||||
|
// Verify status changed to Active
|
||||||
|
const getResp = await apiRequest.get(`/api/v1/integrations/${testId}`);
|
||||||
|
const integration = await getResp.json();
|
||||||
|
expect(integration.status).toBe(1); // Active
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET health returns Healthy after health check', async ({ apiRequest }) => {
|
||||||
|
expect(testId).toBeTruthy();
|
||||||
|
const resp = await apiRequest.get(`/api/v1/integrations/${testId}/health`);
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.status).toBe(1); // Healthy
|
||||||
|
expect(body.checkedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET impact returns workflow impact map', async ({ apiRequest }) => {
|
||||||
|
expect(testId).toBeTruthy();
|
||||||
|
const resp = await apiRequest.get(`/api/v1/integrations/${testId}/impact`);
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.integrationId).toBe(testId);
|
||||||
|
expect(body.type).toBe(1); // Registry
|
||||||
|
expect(body.severity).toBeTruthy();
|
||||||
|
expect(body.impactedWorkflows).toBeDefined();
|
||||||
|
expect(body.impactedWorkflows.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT update changes integration fields', async ({ apiRequest }) => {
|
||||||
|
expect(testId).toBeTruthy();
|
||||||
|
const resp = await apiRequest.put(`/api/v1/integrations/${testId}`, {
|
||||||
|
data: { name: `E2E CRUD Updated ${runId}`, description: 'Updated by E2E test' },
|
||||||
|
});
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
|
||||||
|
expect(body.name).toContain('E2E CRUD Updated');
|
||||||
|
expect(body.description).toBe('Updated by E2E test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE removes the integration', async ({ apiRequest }) => {
|
||||||
|
expect(testId).toBeTruthy();
|
||||||
|
const resp = await apiRequest.delete(`/api/v1/integrations/${testId}`);
|
||||||
|
// Accept 200 or 204 (No Content)
|
||||||
|
expect(resp.status()).toBeLessThan(300);
|
||||||
|
|
||||||
|
// Verify it's gone (404 or empty response)
|
||||||
|
const getResp = await apiRequest.get(`/api/v1/integrations/${testId}`);
|
||||||
|
expect([404, 204, 200]).toContain(getResp.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /providers lists all loaded connector plugins', async ({ apiRequest }) => {
|
||||||
|
const resp = await apiRequest.get('/api/v1/integrations/providers');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const providers = await resp.json();
|
||||||
|
|
||||||
|
expect(providers.length).toBeGreaterThanOrEqual(8);
|
||||||
|
|
||||||
|
// Verify known providers are present
|
||||||
|
const names = providers.map((p: any) => p.name);
|
||||||
|
expect(names).toContain('harbor');
|
||||||
|
expect(names).toContain('gitea');
|
||||||
|
expect(names).toContain('jenkins');
|
||||||
|
expect(names).toContain('nexus');
|
||||||
|
expect(names).toContain('docker-registry');
|
||||||
|
expect(names).toContain('gitlab-server');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. UI Verification
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Integration Services — UI Verification', () => {
|
||||||
|
test('landing page redirects to first populated tab or shows onboarding', async ({ liveAuthPage: page }) => {
|
||||||
|
await page.goto(`${BASE}/setup/integrations`, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(3_000);
|
||||||
|
|
||||||
|
const url = page.url();
|
||||||
|
// Should either redirect to a tab (registries/scm/ci) or show onboarding
|
||||||
|
const isOnTab = url.includes('/registries') || url.includes('/scm') || url.includes('/ci');
|
||||||
|
const hasOnboarding = await page.locator('text=/Get Started/').isVisible().catch(() => false);
|
||||||
|
|
||||||
|
expect(isOnTab || hasOnboarding, 'Should redirect to tab or show onboarding').toBe(true);
|
||||||
|
|
||||||
|
await snap(page, '01-landing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Registries tab lists registry integrations', async ({ liveAuthPage: page }) => {
|
||||||
|
await page.goto(`${BASE}/setup/integrations/registries`, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
const heading = page.getByRole('heading', { name: /registry/i });
|
||||||
|
await expect(heading).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Should have at least one row in the table
|
||||||
|
const rows = page.locator('table tbody tr');
|
||||||
|
const count = await rows.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
await snap(page, '02-registries-tab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SCM tab lists SCM integrations', async ({ liveAuthPage: page }) => {
|
||||||
|
await page.goto(`${BASE}/setup/integrations/scm`, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
const heading = page.getByRole('heading', { name: /scm/i });
|
||||||
|
await expect(heading).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
const rows = page.locator('table tbody tr');
|
||||||
|
const count = await rows.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
await snap(page, '03-scm-tab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CI/CD tab lists CI/CD integrations', async ({ liveAuthPage: page }) => {
|
||||||
|
await page.goto(`${BASE}/setup/integrations/ci`, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
const heading = page.getByRole('heading', { name: /ci\/cd/i });
|
||||||
|
await expect(heading).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
const rows = page.locator('table tbody tr');
|
||||||
|
const count = await rows.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
await snap(page, '04-cicd-tab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tab switching navigates between all tabs', async ({ liveAuthPage: page }) => {
|
||||||
|
await page.goto(`${BASE}/setup/integrations`, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
const tabs = ['Registries', 'SCM', 'CI/CD', 'Runtimes / Hosts', 'Advisory & VEX', 'Secrets'];
|
||||||
|
|
||||||
|
for (const tabName of tabs) {
|
||||||
|
const tab = page.getByRole('tab', { name: tabName });
|
||||||
|
await tab.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify tab is now selected
|
||||||
|
const isSelected = await tab.getAttribute('aria-selected');
|
||||||
|
expect(isSelected, `Tab "${tabName}" should be selected after click`).toBe('true');
|
||||||
|
}
|
||||||
|
|
||||||
|
await snap(page, '05-tab-switching-final');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live auth fixture for integration tests against the real Stella Ops stack.
|
||||||
|
*
|
||||||
|
* Unlike the mocked auth.fixture.ts, this performs a real OIDC login against
|
||||||
|
* the live Authority service and extracts a Bearer token for API calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local';
|
||||||
|
const ADMIN_USER = process.env['STELLAOPS_ADMIN_USER'] || 'admin';
|
||||||
|
const ADMIN_PASS = process.env['STELLAOPS_ADMIN_PASS'] || 'Admin@Stella2026!';
|
||||||
|
|
||||||
|
async function loginAndGetToken(page: Page): Promise<string> {
|
||||||
|
// Navigate to the app
|
||||||
|
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
// Check if already authenticated (session exists)
|
||||||
|
const existingToken = await page.evaluate(() => {
|
||||||
|
const s = sessionStorage.getItem('stellaops.auth.session.full');
|
||||||
|
return s ? JSON.parse(s)?.tokens?.accessToken : null;
|
||||||
|
});
|
||||||
|
if (existingToken) return existingToken;
|
||||||
|
|
||||||
|
// If we land on /welcome, click Sign In
|
||||||
|
if (page.url().includes('/welcome')) {
|
||||||
|
const signInBtn = page.getByRole('button', { name: /sign in/i });
|
||||||
|
await signInBtn.waitFor({ state: 'visible', timeout: 10_000 });
|
||||||
|
await signInBtn.click();
|
||||||
|
await page.waitForTimeout(3_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already on /connect/authorize, fill the login form
|
||||||
|
if (page.url().includes('/connect/')) {
|
||||||
|
const usernameField = page.getByRole('textbox', { name: /username/i });
|
||||||
|
await usernameField.waitFor({ state: 'visible', timeout: 15_000 });
|
||||||
|
await usernameField.fill(ADMIN_USER);
|
||||||
|
await page.getByRole('textbox', { name: /password/i }).fill(ADMIN_PASS);
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the session token to appear in sessionStorage (polls every 500ms)
|
||||||
|
const token = await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const s = sessionStorage.getItem('stellaops.auth.session.full');
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(s);
|
||||||
|
return parsed?.tokens?.accessToken || null;
|
||||||
|
} catch { return null; }
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ timeout: 30_000, polling: 500 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenValue = await token.jsonValue() as string;
|
||||||
|
if (!tokenValue) {
|
||||||
|
throw new Error('Login succeeded but failed to extract auth token from sessionStorage');
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<{
|
||||||
|
liveAuthPage: Page;
|
||||||
|
apiToken: string;
|
||||||
|
apiRequest: APIRequestContext;
|
||||||
|
}>({
|
||||||
|
liveAuthPage: async ({ browser }, use) => {
|
||||||
|
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await loginAndGetToken(page);
|
||||||
|
await use(page);
|
||||||
|
await context.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
apiToken: async ({ browser }, use) => {
|
||||||
|
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const token = await loginAndGetToken(page);
|
||||||
|
await use(token);
|
||||||
|
await context.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
apiRequest: async ({ playwright, apiToken }, use) => {
|
||||||
|
const ctx = await playwright.request.newContext({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
'Authorization': `Bearer ${apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
});
|
||||||
|
await use(ctx);
|
||||||
|
await ctx.dispose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect };
|
||||||
Reference in New Issue
Block a user