mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
* fix: e2e QA hardening — honest runtime URLs, health, and read-only anon UI Backend: - managed DB provisioning: attach DB containers to the platform network and probe the container IP, fixing the 127.0.0.1 reachability failure inside the backend container; log provisioning failures - stop fabricating connection URLs when provisioning produced none - template deploys now carry port + health_check into service runtime fields - service health probed over the project network (backend joins it) instead of an in-container wget that fails on shell-less images - runtime/public/connection URLs rewrite loopback to the request Host so links are usable for LAN/remote clients Frontend: - service Networking panel shows real runtime URLs/ports/health instead of hardcoded containr.dev + 8080->443 - anonymous views hide all mutation controls (new project, add service, group, context menu) - project cards: remove decorative svcN labels; badge says DOWN only when zero services run, IDLE when empty, DEGRADED for partial - mobile projects header no longer clips (fluid search width) - unknown routes render a not-found page instead of silently redirecting Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: telemetry scope label, chunk split, remove stale vite artifacts - /system/host now reports scope=host|docker-desktop-vm and the daemon OS; Projects dashboard labels cards VM.* when the daemon is a Desktop VM so memory/core numbers aren't mistaken for physical host values - manualChunks: split @xyflow/react and recharts out of the main bundle; removes the >1MB chunk warning - drop tracked vite.config.js/.d.ts build artifacts that shadowed the real vite.config.ts Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: persist service host port; demo-data scope for telemetry Public URLs were ephemeral: every restart/redeploy rebound a random host port, so endpoint links rotted. Persist published_port on the service and prefer it for replica 0 on create. If the port is taken, fall back to ephemeral and repersist the new assignment. Post-start the binding is verified — Docker Desktop accepts a conflicting publish silently, which previously left services running with no endpoint at all. demo-data.ts gains the required scope field for HostMonitoring (CI fix). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
234 lines
8.3 KiB
Go
234 lines
8.3 KiB
Go
package api
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/docker/go-connections/nat"
|
|
)
|
|
|
|
func TestNormalizeDatabaseType(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{input: "postgres", want: "postgresql"},
|
|
{input: "postgresql", want: "postgresql"},
|
|
{input: "pg", want: "postgresql"},
|
|
{input: "redis", want: "redis"},
|
|
{input: "dragonflydb", want: "dragonfly"},
|
|
{input: "dragonfly", want: "dragonfly"},
|
|
{input: "mysql", want: "mysql"},
|
|
{input: "mariadb", want: "mariadb"},
|
|
{input: "mongo", want: "mongodb"},
|
|
{input: "mongodb", want: "mongodb"},
|
|
{input: "clickhouse", want: "clickhouse"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := normalizeDatabaseType(tt.input)
|
|
if got != tt.want {
|
|
t.Fatalf("normalizeDatabaseType(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
|
|
handler := &DatabaseHandler{}
|
|
|
|
cases := []struct {
|
|
dbType string
|
|
urlContains string
|
|
version string
|
|
}{
|
|
{dbType: "postgresql", urlContains: "postgresql://", version: "16.2"},
|
|
{dbType: "redis", urlContains: "redis://", version: "7.2"},
|
|
{dbType: "dragonfly", urlContains: "redis://", version: "1.24"},
|
|
{dbType: "mysql", urlContains: "mysql://", version: "8.4"},
|
|
{dbType: "mariadb", urlContains: "mysql://", version: "11.4"},
|
|
{dbType: "mongodb", urlContains: "mongodb://", version: "7.0"},
|
|
{dbType: "clickhouse", urlContains: "http://", version: "24.8"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
plan, err := buildDatabaseRuntimePlan(tc.dbType, "example", nil)
|
|
if err != nil {
|
|
t.Fatalf("buildDatabaseRuntimePlan(%q) failed: %v", tc.dbType, err)
|
|
}
|
|
url := plan.ConnectionURL("15432")
|
|
if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) {
|
|
t.Fatalf("ConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains)
|
|
}
|
|
if !strings.Contains(url, ":15432") {
|
|
t.Fatalf("ConnectionURL(%q) = %q, expected published port", tc.dbType, url)
|
|
}
|
|
|
|
gotVersion := handler.getDefaultVersion(tc.dbType)
|
|
if gotVersion != tc.version {
|
|
t.Fatalf("getDefaultVersion(%q) = %q, want %q", tc.dbType, gotVersion, tc.version)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveConnectionURLNeverFabricates(t *testing.T) {
|
|
handler := &DatabaseHandler{}
|
|
|
|
if got := handler.resolveConnectionURL(DatabaseService{Type: "postgresql", Name: "example"}); got != "" {
|
|
t.Fatalf("resolveConnectionURL with no stored URL = %q, want empty", got)
|
|
}
|
|
if got := handler.resolveConnectionURL(DatabaseService{ConnectionURL: "postgresql://u:p@localhost:15432/db"}); got != "postgresql://u:p@localhost:15432/db" {
|
|
t.Fatalf("resolveConnectionURL = %q, want stored URL", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) {
|
|
cases := []struct {
|
|
dbType string
|
|
expectedImage string
|
|
expectedPort nat.Port
|
|
urlPrefix string
|
|
}{
|
|
{dbType: "postgresql", expectedImage: "postgres:16-alpine", expectedPort: nat.Port("5432/tcp"), urlPrefix: "postgresql://"},
|
|
{dbType: "redis", expectedImage: "redis:7-alpine", expectedPort: nat.Port("6379/tcp"), urlPrefix: "redis://"},
|
|
{dbType: "dragonfly", expectedImage: "docker.dragonflydb.io/dragonflydb/dragonfly:latest", expectedPort: nat.Port("6379/tcp"), urlPrefix: "redis://"},
|
|
{dbType: "mysql", expectedImage: "mysql:8.4", expectedPort: nat.Port("3306/tcp"), urlPrefix: "mysql://"},
|
|
{dbType: "mariadb", expectedImage: "mariadb:11", expectedPort: nat.Port("3306/tcp"), urlPrefix: "mysql://"},
|
|
{dbType: "mongodb", expectedImage: "mongo:7", expectedPort: nat.Port("27017/tcp"), urlPrefix: "mongodb://"},
|
|
{dbType: "clickhouse", expectedImage: "clickhouse/clickhouse-server:24.8", expectedPort: nat.Port("8123/tcp"), urlPrefix: "http://"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
plan, err := buildDatabaseRuntimePlan(tc.dbType, "My DB", nil)
|
|
if err != nil {
|
|
t.Fatalf("buildDatabaseRuntimePlan(%q) returned error: %v", tc.dbType, err)
|
|
}
|
|
if plan.Image != tc.expectedImage {
|
|
t.Fatalf("buildDatabaseRuntimePlan(%q) image=%q want=%q", tc.dbType, plan.Image, tc.expectedImage)
|
|
}
|
|
if plan.Port != tc.expectedPort {
|
|
t.Fatalf("buildDatabaseRuntimePlan(%q) port=%q want=%q", tc.dbType, plan.Port, tc.expectedPort)
|
|
}
|
|
conn := plan.ConnectionURL("12345")
|
|
if !strings.HasPrefix(conn, tc.urlPrefix) {
|
|
t.Fatalf("buildDatabaseRuntimePlan(%q) connectionURL=%q expected prefix=%q", tc.dbType, conn, tc.urlPrefix)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDatabaseRuntimePlanHonorsRuntimeVariables(t *testing.T) {
|
|
plan, err := buildDatabaseRuntimePlan("postgresql", "mydb", map[string]string{
|
|
"POSTGRES_USER": "template_user",
|
|
"POSTGRES_PASSWORD": "template_pass",
|
|
"POSTGRES_DB": "template_db",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("buildDatabaseRuntimePlan returned error: %v", err)
|
|
}
|
|
|
|
envJoined := strings.Join(plan.Env, " ")
|
|
if !strings.Contains(envJoined, "POSTGRES_USER=template_user") {
|
|
t.Fatalf("expected POSTGRES_USER override in env, got: %s", envJoined)
|
|
}
|
|
if !strings.Contains(envJoined, "POSTGRES_PASSWORD=template_pass") {
|
|
t.Fatalf("expected POSTGRES_PASSWORD override in env, got: %s", envJoined)
|
|
}
|
|
if !strings.Contains(envJoined, "POSTGRES_DB=template_db") {
|
|
t.Fatalf("expected POSTGRES_DB override in env, got: %s", envJoined)
|
|
}
|
|
|
|
url := plan.ConnectionURL("54321")
|
|
if !strings.Contains(url, "template_user:template_pass") || !strings.Contains(url, "/template_db") {
|
|
t.Fatalf("expected connection URL to reflect runtime overrides, got: %s", url)
|
|
}
|
|
}
|
|
|
|
func TestGenerateDatabaseIDIncludesRandomSuffix(t *testing.T) {
|
|
id1 := generateDatabaseID("Main DB")
|
|
id2 := generateDatabaseID("Main DB")
|
|
if id1 == id2 {
|
|
t.Fatalf("expected generateDatabaseID to produce unique values, got identical id %q", id1)
|
|
}
|
|
if !strings.HasPrefix(id1, "db_") || !strings.Contains(id1, "_main_db_") {
|
|
t.Fatalf("unexpected database id format: %s", id1)
|
|
}
|
|
}
|
|
|
|
func TestManagedDatabaseNamesAreBoundedAndStable(t *testing.T) {
|
|
id := "db_1234567890_this-is-a-very-very-very-very-very-long-name"
|
|
containerName := managedDatabaseContainerName(id)
|
|
volumeName := managedDatabaseVolumeName(id)
|
|
|
|
if containerName == "" || volumeName == "" {
|
|
t.Fatal("expected non-empty managed runtime names")
|
|
}
|
|
if len(containerName) > 63 {
|
|
t.Fatalf("container name too long: %d", len(containerName))
|
|
}
|
|
if len(volumeName) > 63 {
|
|
t.Fatalf("volume name too long: %d", len(volumeName))
|
|
}
|
|
if !strings.HasPrefix(containerName, "containr-db-") {
|
|
t.Fatalf("unexpected container prefix: %s", containerName)
|
|
}
|
|
if !strings.HasPrefix(volumeName, "containr-db-vol-") {
|
|
t.Fatalf("unexpected volume prefix: %s", volumeName)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeBackupArchivePath(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{input: "backup_abc.tar.gz", want: "backup_abc.tar.gz"},
|
|
{input: "/tmp/../../danger", want: "tmp_danger.tar.gz"},
|
|
{input: " weird name ", want: "weird_name.tar.gz"},
|
|
{input: "", want: "backup.tar.gz"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := sanitizeBackupArchivePath(tt.input)
|
|
if got != tt.want {
|
|
t.Fatalf("sanitizeBackupArchivePath(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHumanReadableBytes(t *testing.T) {
|
|
tests := []struct {
|
|
size int64
|
|
want string
|
|
}{
|
|
{size: 0, want: "0 B"},
|
|
{size: 1024, want: "1.00 KB"},
|
|
{size: 1048576, want: "1.00 MB"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := humanReadableBytes(tt.size)
|
|
if got != tt.want {
|
|
t.Fatalf("humanReadableBytes(%d) = %q, want %q", tt.size, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRewriteLoopbackURL(t *testing.T) {
|
|
tests := []struct {
|
|
name, raw, host, want string
|
|
}{
|
|
{"service url", "http://localhost:35981", "10.0.0.182", "http://10.0.0.182:35981"},
|
|
{"db url keeps creds", "postgresql://u:p@localhost:35432/db?sslmode=disable", "10.0.0.182", "postgresql://u:[email protected]:35432/db?sslmode=disable"},
|
|
{"ipv4 loopback", "redis://:[email protected]:36379/0", "host.lan", "redis://:[email protected]:36379/0"},
|
|
{"unspecified", "http://0.0.0.0:9000/x", "10.0.0.182", "http://10.0.0.182:9000/x"},
|
|
{"external host untouched", "https://example.com:443/x", "10.0.0.182", "https://example.com:443/x"},
|
|
{"empty raw", "", "10.0.0.182", ""},
|
|
{"empty host", "http://localhost:35981", "", "http://localhost:35981"},
|
|
{"no port", "http://localhost/path", "10.0.0.182", "http://10.0.0.182/path"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := rewriteLoopbackURL(tt.raw, tt.host); got != tt.want {
|
|
t.Errorf("%s: rewriteLoopbackURL(%q,%q) = %q, want %q", tt.name, tt.raw, tt.host, got, tt.want)
|
|
}
|
|
}
|
|
}
|