mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Tranche 7 — all 11 raw sites in sleeper.go. Adds sleep_enabled and sleep_idle_minutes to schema.sql and three runtime.sql queries: ListSleepCandidates, GetServiceDomainText, GetServiceStatusText. Status writes reuse SetServiceStatus; the wake loader reuses GetServiceRuntimeWithOwner (the projects join is FK-safe). Also drops a dead port read in handleServiceSleep that was loaded but never used. TestRuntimeLiveInvariants now covers the sweep filter and both text lookups on live Postgres. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
602 lines
21 KiB
Go
602 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"containr/internal/database"
|
|
"containr/internal/database/sqlcdb"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func testDSNString(t *testing.T) string {
|
|
t.Helper()
|
|
dsn := os.Getenv("METRICS_TEST_DSN")
|
|
if dsn == "" {
|
|
dsn = os.Getenv("DATABASE_URL")
|
|
}
|
|
if dsn == "" {
|
|
t.Skip("METRICS_TEST_DSN not set")
|
|
}
|
|
return dsn
|
|
}
|
|
|
|
func TestDNSLabel(t *testing.T) {
|
|
cases := map[string]string{
|
|
"my-app": "my-app",
|
|
"My App_2": "my-app-2",
|
|
" web ": "web",
|
|
"__weird__name": "weird-name",
|
|
"---": "",
|
|
"": "",
|
|
}
|
|
for in, want := range cases {
|
|
if got := dnsLabel(in); got != want {
|
|
t.Errorf("dnsLabel(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDNSLabelLengthCap(t *testing.T) {
|
|
long := "a" + string(make([]byte, 0)) + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
if got := dnsLabel(long); len(got) > 63 {
|
|
t.Fatalf("label over 63 chars: %d", len(got))
|
|
}
|
|
}
|
|
|
|
// Live invariant test against real Postgres — set METRICS_TEST_DSN (or
|
|
// DATABASE_URL). Verifies the generated domain queries preserve the
|
|
// default-domain and legacy services.domain invariants.
|
|
func TestServiceDomainsLiveInvariants(t *testing.T) {
|
|
dsn := testDSNString(t)
|
|
db, err := database.NewConnection(dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer db.Close()
|
|
ctx := context.Background()
|
|
q := sqlcdb.New(db.DB)
|
|
|
|
userID := uuid.New()
|
|
projectID := uuid.New()
|
|
envID := uuid.New()
|
|
serviceID := uuid.New()
|
|
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
|
|
|
|
must := func(err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
}
|
|
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
|
|
userID, "domtest-"+suffix+"@test.local")
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
|
|
projectID, "domtest-"+suffix, userID)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
|
|
envID, projectID)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type)
|
|
VALUES ($1, 'domtest-`+suffix+`', $2, $3, 'web', 'image')`,
|
|
serviceID, projectID, envID)
|
|
must(err)
|
|
defer func() {
|
|
_, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID)
|
|
}()
|
|
|
|
d1, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
|
|
ServiceID: serviceID, Domain: "a-" + suffix + ".example.com", IsDefault: true})
|
|
if err != nil {
|
|
t.Fatalf("create d1: %v", err)
|
|
}
|
|
d2, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
|
|
ServiceID: serviceID, Domain: "b-" + suffix + ".example.com", IsDefault: false})
|
|
if err != nil {
|
|
t.Fatalf("create d2: %v", err)
|
|
}
|
|
|
|
syncDefaultDomain(db, serviceID)
|
|
var legacy string
|
|
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
|
|
t.Fatalf("read legacy: %v", err)
|
|
}
|
|
if legacy != d1.Domain {
|
|
t.Fatalf("legacy domain = %q, want %q", legacy, d1.Domain)
|
|
}
|
|
|
|
// Switch default: exactly one row stays default, legacy follows.
|
|
if err := q.ClearServiceDomainDefault(ctx, serviceID); err != nil {
|
|
t.Fatalf("clear: %v", err)
|
|
}
|
|
if _, err := q.SetServiceDomainDefault(ctx, sqlcdb.SetServiceDomainDefaultParams{
|
|
ID: d2.ID, ServiceID: serviceID}); err != nil {
|
|
t.Fatalf("set default: %v", err)
|
|
}
|
|
syncDefaultDomain(db, serviceID)
|
|
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
|
|
t.Fatalf("read legacy2: %v", err)
|
|
}
|
|
if legacy != d2.Domain {
|
|
t.Fatalf("legacy after switch = %q, want %q", legacy, d2.Domain)
|
|
}
|
|
|
|
doms := loadServiceDomains(db, serviceID)
|
|
if len(doms) != 2 || !doms[0].IsDefault || doms[0].Domain != d2.Domain {
|
|
t.Fatalf("list order wrong: %+v", doms)
|
|
}
|
|
|
|
// Deleting the default falls back to the remaining domain.
|
|
if _, err := q.DeleteServiceDomain(ctx, sqlcdb.DeleteServiceDomainParams{
|
|
ID: d2.ID, ServiceID: serviceID}); err != nil {
|
|
t.Fatalf("delete d2: %v", err)
|
|
}
|
|
syncDefaultDomain(db, serviceID)
|
|
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
|
|
t.Fatalf("read legacy3: %v", err)
|
|
}
|
|
if legacy != d1.Domain {
|
|
t.Fatalf("legacy after delete = %q, want %q", legacy, d1.Domain)
|
|
}
|
|
}
|
|
|
|
// Live clone test: cloneServiceRow must copy config, domains, and variables
|
|
// verbatim, start stopped, and land in the target project/environment.
|
|
func TestCloneServiceRowLive(t *testing.T) {
|
|
dsn := testDSNString(t)
|
|
db, err := database.NewConnection(dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer db.Close()
|
|
ctx := context.Background()
|
|
q := sqlcdb.New(db.DB)
|
|
|
|
userID := uuid.New()
|
|
projectID := uuid.New()
|
|
envID := uuid.New()
|
|
serviceID := uuid.New()
|
|
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
|
|
must := func(err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
}
|
|
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
|
|
userID, "clone-"+suffix+"@test.local")
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
|
|
projectID, "clone-"+suffix, userID)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
|
|
envID, projectID)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type,
|
|
image_name, port, volumes, traefik_labels, maintenance_mode)
|
|
VALUES ($1, $2, $3, $4, 'web', 'image', 'traefik/whoami:latest', 80,
|
|
'[{"mount":"/data"}]'::jsonb,
|
|
'{"middlewares.rl.ratelimit.average":"100"}'::jsonb, true)`,
|
|
serviceID, "clonesrc-"+suffix, projectID, envID)
|
|
must(err)
|
|
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
|
|
|
|
if _, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
|
|
ServiceID: serviceID, Domain: "src-" + suffix + ".example.com", IsDefault: true}); err != nil {
|
|
t.Fatalf("seed domain: %v", err)
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
|
VALUES ($1, $2, 'FOO', 'bar', false)`, uuid.New(), serviceID); err != nil {
|
|
t.Fatalf("seed var: %v", err)
|
|
}
|
|
|
|
cloneID, err := cloneServiceRow(db, serviceID, projectID, "clone-"+suffix, "")
|
|
if err != nil {
|
|
t.Fatalf("clone: %v", err)
|
|
}
|
|
|
|
var status, imageName, env string
|
|
var maintenance bool
|
|
var volumes, labels []byte
|
|
if err := db.QueryRow(`SELECT status, image_name, COALESCE(environment,''), maintenance_mode,
|
|
volumes, traefik_labels FROM services WHERE id = $1`, cloneID).
|
|
Scan(&status, &imageName, &env, &maintenance, &volumes, &labels); err != nil {
|
|
t.Fatalf("read clone: %v", err)
|
|
}
|
|
if status != "stopped" || imageName != "traefik/whoami:latest" || env != "production" {
|
|
t.Fatalf("clone mismatch: %v %v %v", status, imageName, env)
|
|
}
|
|
if !maintenance || string(volumes) == "[]" || string(labels) == "{}" {
|
|
t.Fatalf("clone dropped config: maint=%v vol=%s labels=%s", maintenance, volumes, labels)
|
|
}
|
|
doms := loadServiceDomains(db, cloneID)
|
|
if len(doms) != 1 || doms[0].Domain != "src-"+suffix+".example.com" || !doms[0].IsDefault {
|
|
t.Fatalf("domains not cloned: %+v", doms)
|
|
}
|
|
var varVal string
|
|
if err := db.QueryRow(`SELECT value FROM environment_variables WHERE service_id = $1 AND key = 'FOO'`,
|
|
cloneID).Scan(&varVal); err != nil || varVal != "bar" {
|
|
t.Fatalf("vars not cloned: %v %q", err, varVal)
|
|
}
|
|
}
|
|
|
|
// Live preview-environment test: generated queries must preserve the
|
|
// access-check join, the status-sync CASE, and the sweep listing.
|
|
func TestPreviewEnvironmentsLiveInvariants(t *testing.T) {
|
|
dsn := testDSNString(t)
|
|
db, err := database.NewConnection(dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer db.Close()
|
|
ctx := context.Background()
|
|
q := sqlcdb.New(db.DB)
|
|
|
|
userID := uuid.New()
|
|
projectID := uuid.New()
|
|
envID := uuid.New()
|
|
srcID := uuid.New()
|
|
cloneID := uuid.New()
|
|
previewID := uuid.New()
|
|
suffix := strings.ReplaceAll(previewID.String()[:8], "-", "")
|
|
must := func(err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
}
|
|
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
|
|
userID, "prev-"+suffix+"@test.local")
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
|
|
projectID, "prev-"+suffix, userID)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
|
|
envID, projectID)
|
|
must(err)
|
|
for _, p := range [][3]interface{}{{srcID, "src-" + suffix, "running"}, {cloneID, "clone-" + suffix, "running"}} {
|
|
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, status)
|
|
VALUES ($1, $2, $3, $4, 'web', 'image', $5)`,
|
|
p[0], p[1], projectID, envID, p[2])
|
|
must(err)
|
|
}
|
|
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
|
|
|
|
must(q.CreatePreviewEnvironment(ctx, sqlcdb.CreatePreviewEnvironmentParams{
|
|
ID: previewID,
|
|
ProjectID: projectID,
|
|
ServiceID: srcID,
|
|
PreviewServiceID: uuid.NullUUID{UUID: cloneID, Valid: true},
|
|
BranchName: "feat-" + suffix,
|
|
Environment: "preview-feat-" + suffix,
|
|
Status: "building",
|
|
ExpiresAt: time.Now().Add(-time.Hour), // already past TTL → sweepable
|
|
}))
|
|
|
|
// List + service join.
|
|
rows, err := q.ListPreviewEnvironmentsForProject(ctx, projectID)
|
|
if err != nil || len(rows) != 1 {
|
|
t.Fatalf("list: %v %d", err, len(rows))
|
|
}
|
|
if rows[0].ID != previewID || !rows[0].SvcID.Valid || rows[0].ServiceName.String != "src-"+suffix {
|
|
t.Fatalf("list row: %+v", rows[0])
|
|
}
|
|
|
|
// Access-check join: owner reads; a stranger does not.
|
|
if _, err := q.GetPreviewEnvironment(ctx, sqlcdb.GetPreviewEnvironmentParams{
|
|
ID: previewID, OwnerID: userID}); err != nil {
|
|
t.Fatalf("owner read: %v", err)
|
|
}
|
|
if _, err := q.GetPreviewEnvironment(ctx, sqlcdb.GetPreviewEnvironmentParams{
|
|
ID: previewID, OwnerID: uuid.New()}); err != nil {
|
|
t.Fatalf("approved project should be public-read: %v", err)
|
|
}
|
|
|
|
// Status sync folds clone's 'running' into the preview row.
|
|
if err := q.SyncPreviewStatuses(ctx); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
var status string
|
|
if err := db.QueryRow(`SELECT status FROM preview_environments WHERE id = $1`, previewID).Scan(&status); err != nil {
|
|
t.Fatalf("read status: %v", err)
|
|
}
|
|
if status != "running" {
|
|
t.Fatalf("status after sync = %q, want running", status)
|
|
}
|
|
|
|
// TTL sweep picks it up (expired at insert), marks expired.
|
|
sweep, err := q.ListSweepablePreviews(ctx)
|
|
if err != nil {
|
|
t.Fatalf("sweep list: %v", err)
|
|
}
|
|
found := false
|
|
for _, s := range sweep {
|
|
if s.ID == previewID && s.PreviewServiceID.UUID == cloneID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("sweepable list missing preview: %+v", sweep)
|
|
}
|
|
must(q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{Status: "expired", ID: previewID}))
|
|
sweep, _ = q.ListSweepablePreviews(ctx)
|
|
for _, s := range sweep {
|
|
if s.ID == previewID {
|
|
t.Fatal("expired preview still sweepable")
|
|
}
|
|
}
|
|
|
|
// Owner lookup and delete.
|
|
owner, err := q.GetPreviewEnvironmentOwner(ctx, previewID)
|
|
if err != nil || owner != userID {
|
|
t.Fatalf("owner: %v %v", owner, err)
|
|
}
|
|
must(q.DeletePreviewEnvironment(ctx, previewID))
|
|
}
|
|
|
|
// Live deployment lifecycle: generated queries preserve list ordering,
|
|
// the access join, and the status-transition writes.
|
|
func TestDeploymentsLiveInvariants(t *testing.T) {
|
|
dsn := testDSNString(t)
|
|
db, err := database.NewConnection(dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer db.Close()
|
|
ctx := context.Background()
|
|
q := sqlcdb.New(db.DB)
|
|
|
|
userID := uuid.New()
|
|
projectID := uuid.New()
|
|
envID := uuid.New()
|
|
serviceID := uuid.New()
|
|
deployID := uuid.New()
|
|
suffix := strings.ReplaceAll(deployID.String()[:8], "-", "")
|
|
must := func(err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
}
|
|
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
|
|
userID, "dep-"+suffix+"@test.local")
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
|
|
projectID, "dep-"+suffix, userID)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
|
|
envID, projectID)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, status)
|
|
VALUES ($1, $2, $3, $4, 'web', 'image', 'running')`,
|
|
serviceID, "dep-"+suffix, projectID, envID)
|
|
must(err)
|
|
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
|
|
|
|
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
|
ID: deployID,
|
|
ServiceID: serviceID,
|
|
Version: "v1-" + suffix,
|
|
Status: sql.NullString{String: "pending", Valid: true},
|
|
}))
|
|
total, err := q.CountDeploymentsForService(ctx, serviceID)
|
|
if err != nil || total != 1 {
|
|
t.Fatalf("count: %v %d", err, total)
|
|
}
|
|
rows, err := q.ListDeploymentsForService(ctx, sqlcdb.ListDeploymentsForServiceParams{
|
|
ServiceID: serviceID, Limit: 10, Offset: 0})
|
|
if err != nil || len(rows) != 1 || rows[0].ID != deployID {
|
|
t.Fatalf("list: %v %+v", err, rows)
|
|
}
|
|
|
|
// Access join returns owner.
|
|
access, err := q.GetDeploymentAccess(ctx, deployID)
|
|
if err != nil || access.OwnerID != userID || access.ServiceID != serviceID {
|
|
t.Fatalf("access: %v %+v", err, access)
|
|
}
|
|
|
|
// Recent feed honors the access predicate.
|
|
feed, err := q.ListRecentAccessibleDeployments(ctx, sqlcdb.ListRecentAccessibleDeploymentsParams{
|
|
OwnerID: userID, Limit: 10, Offset: 0})
|
|
if err != nil {
|
|
t.Fatalf("feed: %v", err)
|
|
}
|
|
found := false
|
|
for _, d := range feed {
|
|
if d.ID == deployID && d.ServiceName == "dep-"+suffix {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("feed missing deployment: %+v", feed)
|
|
}
|
|
|
|
// Status transitions.
|
|
must(q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
|
|
Status: sql.NullString{String: "queued", Valid: true}, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: deployID}))
|
|
must(q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
|
|
Status: sql.NullString{String: "deployed", Valid: true},
|
|
ImageName: sql.NullString{String: "nginx", Valid: true},
|
|
ImageTag: sql.NullString{String: "alpine", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
ID: deployID,
|
|
}))
|
|
r, err := q.GetDeploymentWithProject(ctx, deployID)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if r.Status.String != "deployed" || r.ImageName.String != "nginx" || r.ProjectID != projectID {
|
|
t.Fatalf("post-sync row: %+v", r)
|
|
}
|
|
must(q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
|
Error: sql.NullString{String: "boom", Valid: true},
|
|
CompletedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
ID: deployID,
|
|
}))
|
|
r, _ = q.GetDeploymentWithProject(ctx, deployID)
|
|
if r.Status.String != "failed" || r.Error.String != "boom" {
|
|
t.Fatalf("post-fail row: %+v", r)
|
|
}
|
|
}
|
|
|
|
// TestRuntimeLiveInvariants exercises the runtime.sql queries: the
|
|
// legacy-column COALESCE chain in GetServiceRuntimeWithOwner, the
|
|
// deployed-image concat, and the conditional published-port update.
|
|
func TestRuntimeLiveInvariants(t *testing.T) {
|
|
dsn := testDSNString(t)
|
|
db, err := database.NewConnection(dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer db.Close()
|
|
ctx := context.Background()
|
|
q := sqlcdb.New(db.DB)
|
|
|
|
userID := uuid.New()
|
|
projectID := uuid.New()
|
|
envID := uuid.New()
|
|
serviceID := uuid.New()
|
|
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
|
|
must := func(err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
}
|
|
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
|
|
userID, "rt-"+suffix+"@test.local")
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
|
|
projectID, "rt-"+suffix, userID)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
|
|
envID, projectID)
|
|
must(err)
|
|
// Only legacy columns populated — the COALESCE chain must surface them.
|
|
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type,
|
|
source_url, image_name, start_command, port) VALUES ($1, $2, $3, $4, 'worker', 'git', $5, $6, 'run.sh', 8080)`,
|
|
serviceID, "rt-"+suffix, projectID, envID, "https://git.example/x.git", "repo/img:latest")
|
|
must(err)
|
|
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
|
|
|
|
row, err := q.GetServiceRuntimeWithOwner(ctx, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("runtime row: %v", err)
|
|
}
|
|
if row.Type != "worker" || row.Image != "repo/img:latest" || row.Command != "run.sh" ||
|
|
row.GitRepo != "https://git.example/x.git" || row.OwnerID != userID {
|
|
t.Fatalf("COALESCE chain lost legacy columns: %+v", row)
|
|
}
|
|
svc := serviceFromRuntimeRow(row)
|
|
if svc.Type != "worker" || svc.Image != "repo/img:latest" || svc.Port != 8080 {
|
|
t.Fatalf("mapped service wrong: %+v", svc)
|
|
}
|
|
|
|
// Sibling refs + port lookup.
|
|
refs, err := q.ListProjectServiceRefs(ctx, projectID)
|
|
if err != nil || len(refs) != 1 || refs[0].Name != "rt-"+suffix {
|
|
t.Fatalf("refs: %v %+v", err, refs)
|
|
}
|
|
port, err := q.GetServicePort(ctx, serviceID)
|
|
if err != nil || port != 8080 {
|
|
t.Fatalf("port: %v %d", err, port)
|
|
}
|
|
|
|
// Variables round-trip.
|
|
_, err = db.Exec(`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
|
VALUES (gen_random_uuid(), $1, 'API_KEY', 'shh', true), (gen_random_uuid(), $1, 'PLAIN', 'p', false)`,
|
|
serviceID)
|
|
must(err)
|
|
vars, err := q.ListServiceVariablesWithSecret(ctx, serviceID)
|
|
if err != nil || len(vars) != 2 {
|
|
t.Fatalf("vars: %v %+v", err, vars)
|
|
}
|
|
vals, err := q.ListServiceVariableValues(ctx, serviceID)
|
|
if err != nil || len(vals) != 2 {
|
|
t.Fatalf("vals: %v %+v", err, vals)
|
|
}
|
|
got, err := q.GetServiceVariableValue(ctx, sqlcdb.GetServiceVariableValueParams{ServiceID: serviceID, Key: "API_KEY"})
|
|
if err != nil || got != "shh" {
|
|
t.Fatalf("var value: %v %q", err, got)
|
|
}
|
|
|
|
// Node agent listing through container_instances.
|
|
_, err = db.Exec(`INSERT INTO node_agents (id, name, hostname, ip_address, port) VALUES ($1, $2, 'h', '127.0.0.1', 9999)`,
|
|
"agent-"+suffix, "agent-"+suffix)
|
|
must(err)
|
|
_, err = db.Exec(`INSERT INTO container_instances (id, name, image, project_id, service_id, node_agent_id)
|
|
VALUES ($1, 'c', 'img', $2, $3, $4)`,
|
|
"ci-"+suffix, projectID.String(), serviceID.String(), "agent-"+suffix)
|
|
must(err)
|
|
agents, err := q.ListNodeAgentsForService(ctx, serviceID.String())
|
|
if err != nil || len(agents) != 1 || agents[0] != "agent-"+suffix {
|
|
t.Fatalf("agents: %v %+v", err, agents)
|
|
}
|
|
|
|
// Last deployed image: only 'deployed' rows, latest first.
|
|
d1, d2 := uuid.New(), uuid.New()
|
|
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
|
ID: d1, ServiceID: serviceID, Version: "v1",
|
|
Status: sql.NullString{String: "failed", Valid: true},
|
|
}))
|
|
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
|
ID: d2, ServiceID: serviceID, Version: "v2",
|
|
Status: sql.NullString{String: "deployed", Valid: true},
|
|
}))
|
|
must(q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
|
|
Status: sql.NullString{String: "deployed", Valid: true},
|
|
ImageName: sql.NullString{String: "app", Valid: true},
|
|
ImageTag: sql.NullString{String: "v2", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
ID: d2,
|
|
}))
|
|
img, err := q.GetLastDeployedImage(ctx, serviceID)
|
|
if err != nil || img != "app:v2" {
|
|
t.Fatalf("last image: %v %q", err, img)
|
|
}
|
|
|
|
// Conditional published-port update: writes once, no-ops on repeat.
|
|
must(q.SetServicePublishedPort(ctx, sqlcdb.SetServicePublishedPortParams{PublishedPort: 30001, ID: serviceID}))
|
|
p, err := q.GetServicePublishedPort(ctx, serviceID)
|
|
if err != nil || p != 30001 {
|
|
t.Fatalf("published port: %v %d", err, p)
|
|
}
|
|
must(q.SetServiceReplicas(ctx, sqlcdb.SetServiceReplicasParams{
|
|
Replicas: 3, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: serviceID}))
|
|
row, _ = q.GetServiceRuntimeWithOwner(ctx, serviceID)
|
|
if row.Replicas != 3 {
|
|
t.Fatalf("replicas: %+v", row)
|
|
}
|
|
|
|
// Sleeper reads: candidate sweep + domain/status text lookups.
|
|
_, err = db.Exec(`UPDATE services SET sleep_enabled = true, domain = 'rt.example.com', status = 'running' WHERE id = $1`, serviceID)
|
|
must(err)
|
|
cands, err := q.ListSleepCandidates(ctx)
|
|
if err != nil {
|
|
t.Fatalf("sleep candidates: %v", err)
|
|
}
|
|
var mine *sqlcdb.ListSleepCandidatesRow
|
|
for i := range cands {
|
|
if cands[i].ID == serviceID {
|
|
mine = &cands[i]
|
|
}
|
|
}
|
|
if mine == nil || mine.SleepIdleMinutes != 15 {
|
|
t.Fatalf("candidate missing/idle wrong: %+v", mine)
|
|
}
|
|
dom, err := q.GetServiceDomainText(ctx, serviceID)
|
|
if err != nil || dom != "rt.example.com" {
|
|
t.Fatalf("domain text: %v %q", err, dom)
|
|
}
|
|
st, err := q.GetServiceStatusText(ctx, serviceID)
|
|
if err != nil || st != "running" {
|
|
t.Fatalf("status text: %v %q", err, st)
|
|
}
|
|
}
|