CI / test (push) Failing after 19s
DEV-000073 is NOT DONE; current work is partial/stabilized WIP only. go test ./cmd/devctl was last verified PASS. Remaining bootstrap/secret integration requires a stronger long-horizon model. This commit is preservation-only; current ancestry includes DEV-000072 candidate 36fec6c05cdf7aaf413fe... and must be reconciled before DEV-000073 promotion.
645 lines
20 KiB
Go
645 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDiscoverComposeInputsRequiredAndOptional(t *testing.T) {
|
|
content := `A=${POSTGRES_SUPERUSER}
|
|
B=${POSTGRES_DB:-assistant}
|
|
C=${CREATOR_PASSWORD_FILE}
|
|
D=${FOO_CREDENTIAL_REF}
|
|
`
|
|
inputs := discoverComposeInputs(content)
|
|
if _, ok := inputs["POSTGRES_SUPERUSER"]; !ok {
|
|
t.Fatal("POSTGRES_SUPERUSER should be discovered")
|
|
}
|
|
if !inputs["POSTGRES_SUPERUSER"].required {
|
|
t.Fatalf("POSTGRES_SUPERUSER expected required")
|
|
}
|
|
if inputs["POSTGRES_DB"].required {
|
|
t.Fatalf("POSTGRES_DB expected optional")
|
|
}
|
|
if !inputs["CREATOR_PASSWORD_FILE"].required {
|
|
t.Fatalf("CREATOR_PASSWORD_FILE expected required from compose placeholder")
|
|
}
|
|
if !inputs["EPISTEMICS_POSTGRES_CREDENTIAL_REF"].required {
|
|
t.Fatalf("required production bootstrap variable should be required")
|
|
}
|
|
if got := len(inputs); got < 4 {
|
|
t.Fatalf("expected at least 4 discovered inputs, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveDeploymentInputsRejectsAmbientProduction(t *testing.T) {
|
|
t.Setenv("POSTGRES_SUPERUSER", "from_ambient")
|
|
inputs := map[string]composeInput{
|
|
"POSTGRES_SUPERUSER": {name: "POSTGRES_SUPERUSER", required: true},
|
|
}
|
|
resolved, _, _, err := resolveDeploymentInputs(inputs, "", true)
|
|
if err != nil {
|
|
t.Fatalf("resolveDeploymentInputs error: %v", err)
|
|
}
|
|
if len(resolved) != 1 {
|
|
t.Fatalf("expected one resolved input, got %d", len(resolved))
|
|
}
|
|
if resolved[0].Source != "ambient" || resolved[0].Resolved {
|
|
t.Fatalf("expected unresolved ambient resolution, got source=%s resolved=%v", resolved[0].Source, resolved[0].Resolved)
|
|
}
|
|
}
|
|
|
|
func TestDeployPrepareRequiresEnvFileForProduction(t *testing.T) {
|
|
root := t.TempDir()
|
|
composeFile := filepath.Join(root, "docker-compose.prod.yml")
|
|
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := deployPrepare([]string{"--target", "production", "--compose", composeFile})
|
|
if err == nil || !strings.Contains(err.Error(), "production prepare requires --env-file") {
|
|
t.Fatalf("expected missing env-file failure, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeployPrepareBlockedAndApplyRefusesMissingRequiredInput(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoRoot, err := gitRepositoryRoot(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
composeFile := filepath.Join(root, "docker-compose.prod.yml")
|
|
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envFile := filepath.Join(root, "production.env")
|
|
parts := make([]string, 0, len(requiredChatOnlyInputs))
|
|
for key := range requiredChatOnlyInputs {
|
|
if key == "POSTGRES_SUPERUSER" {
|
|
continue
|
|
}
|
|
parts = append(parts, key+"=value-"+key)
|
|
}
|
|
if err := os.WriteFile(envFile, []byte(strings.Join(parts, "\n")), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
target, err := resolveDeploymentTarget("production")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target.ComposeFile = composeFile
|
|
plan, err := buildDeploymentPlan(repoRoot, target, envFile, "HEAD", 24, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if plan.Readiness != readinessFailed {
|
|
t.Fatalf("expected blocked plan, got readiness=%s", plan.Readiness)
|
|
}
|
|
if !strings.Contains(strings.Join(plan.ReadinessReasons, "\n"), "POSTGRES_SUPERUSER") {
|
|
t.Fatalf("expected missing required input blocker for POSTGRES_SUPERUSER, got %v", plan.ReadinessReasons)
|
|
}
|
|
|
|
planPath := filepath.Join(root, "blocked-plan.json")
|
|
payload, err := json.Marshal(plan)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(planPath, payload, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := deployApply([]string{"-plan", planPath}); err == nil {
|
|
t.Fatal("expected apply refusal for blocked plan")
|
|
}
|
|
}
|
|
|
|
func TestAssertPlanCurrentRejectsStaleInputChange(t *testing.T) {
|
|
root := t.TempDir()
|
|
composeFile := filepath.Join(root, "docker-compose.prod.yml")
|
|
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envFile := filepath.Join(root, "deploy.env")
|
|
if err := os.WriteFile(envFile, []byte("POSTGRES_SUPERUSER=first\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
inputs, err := discoverComposeInputsFromFile(composeFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resolved, _, _, err := resolveDeploymentInputs(inputs, envFile, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan := &deploymentPlan{
|
|
Version: 1,
|
|
PlanID: "stale-test",
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
|
|
Readiness: readinessReady,
|
|
ReadinessReasons: nil,
|
|
Target: "production",
|
|
ComposeFile: composeFile,
|
|
DockerContext: "docker-prod",
|
|
ProjectName: "assistant",
|
|
EnvFile: envFile,
|
|
SourceRevision: "11a6a23f6e4f44dd88265a04846419a27f54df98",
|
|
SourceRevisionMode: "explicit",
|
|
ArtifactRevision: "11a6a23f6e4f44dd88265a04846419a27f54df98",
|
|
DeploymentConfigRev: resolveConfigHash(composeFile),
|
|
CurrentProductionRev: "",
|
|
KnownGoodRevision: "",
|
|
Inputs: resolved,
|
|
Classify: map[string][]string{},
|
|
KnownInputsSource: map[string]string{},
|
|
}
|
|
target, err := resolveDeploymentTarget("production")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan.PlanBindingHash = buildPlanBindingFromInputs(
|
|
resolved,
|
|
plan.SourceRevision,
|
|
plan.ArtifactRevision,
|
|
plan.DeploymentConfigRev,
|
|
target.DockerContext,
|
|
target.Containers,
|
|
target.Volumes,
|
|
)
|
|
if err := os.WriteFile(envFile, []byte("POSTGRES_SUPERUSER=second\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := assertPlanCurrent(plan); err == nil {
|
|
t.Fatal("expected stale plan after input change")
|
|
}
|
|
}
|
|
|
|
func TestAssertPlanCurrentRejectsArtifactMismatch(t *testing.T) {
|
|
root := t.TempDir()
|
|
composeFile := filepath.Join(root, "docker-compose.prod.yml")
|
|
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envFile := filepath.Join(root, "deploy.env")
|
|
|
|
envParts := make([]string, 0, len(requiredChatOnlyInputs))
|
|
for key := range requiredChatOnlyInputs {
|
|
envParts = append(envParts, key+"=value-"+strings.ToLower(key))
|
|
}
|
|
envParts = append(envParts, "POSTGRES_SUPERUSER=from_file")
|
|
if err := os.WriteFile(envFile, []byte(strings.Join(envParts, "\n")), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
inputs, err := discoverComposeInputsFromFile(composeFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
discovered, _, _, err := resolveDeploymentInputs(inputs, envFile, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target, err := resolveDeploymentTarget("production")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
configRev := resolveConfigHash(composeFile)
|
|
shaA := strings.Repeat("a", 40)
|
|
shaB := strings.Repeat("b", 40)
|
|
plan := &deploymentPlan{
|
|
Version: 1,
|
|
PlanID: "artifact-mismatch",
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
|
|
Readiness: readinessReady,
|
|
Target: "production",
|
|
ComposeFile: composeFile,
|
|
DockerContext: "docker-prod",
|
|
ProjectName: "assistant",
|
|
EnvFile: envFile,
|
|
SourceRevision: shaA,
|
|
SourceRevisionMode: "explicit",
|
|
ArtifactRevision: shaA,
|
|
DeploymentConfigRev: configRev,
|
|
Inputs: discovered,
|
|
Classify: map[string][]string{},
|
|
KnownInputsSource: map[string]string{},
|
|
}
|
|
plan.PlanBindingHash = buildPlanBindingFromInputs(
|
|
discovered,
|
|
shaA,
|
|
shaA,
|
|
configRev,
|
|
target.DockerContext,
|
|
target.Containers,
|
|
target.Volumes,
|
|
)
|
|
|
|
plan.ArtifactRevision = shaB
|
|
plan.SourceRevision = shaB
|
|
if err := assertPlanCurrent(plan); err == nil {
|
|
t.Fatal("expected stale plan due artifact mismatch")
|
|
}
|
|
}
|
|
|
|
func TestMaxMigrationPrefixFromListHandlesNestedMigrationPaths(t *testing.T) {
|
|
got := maxMigrationPrefixFromList([]string{
|
|
"database/migrations/001_bootstrap.sql",
|
|
"database/migrations/007_legacy.sql",
|
|
"database/migrations/work/015_capability.sql",
|
|
"database/migrations/README.md",
|
|
"database/migrations/artifact/019_work.sql",
|
|
})
|
|
if got != "19" {
|
|
t.Fatalf("expected max migration prefix 19, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAssertPlanCurrentRejectsMigrationUpgradeWithoutKnownGood(t *testing.T) {
|
|
root, err := gitRepositoryRoot(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
headOut, err := runGit(root, "rev-parse", "HEAD")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
targetRevision := strings.TrimSpace(string(headOut))
|
|
targetMaxStr, err := maxMigrationPrefixForRevision(root, targetRevision)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
targetMax, err := strconv.Atoi(targetMaxStr)
|
|
if err != nil {
|
|
t.Fatalf("invalid target migration max %q: %v", targetMaxStr, err)
|
|
}
|
|
|
|
revisionsOut, err := runGit(root, "rev-list", "HEAD", "--max-count", "20")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ancestorList := strings.Fields(string(revisionsOut))
|
|
currentProductionRevision := ""
|
|
for _, revision := range ancestorList {
|
|
if strings.TrimSpace(revision) == targetRevision {
|
|
continue
|
|
}
|
|
revMaxStr, err := maxMigrationPrefixForRevision(root, revision)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
revMax, err := strconv.Atoi(revMaxStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if revMax < targetMax {
|
|
currentProductionRevision = revision
|
|
break
|
|
}
|
|
}
|
|
if currentProductionRevision == "" {
|
|
t.Skip("could not find historical revision with lower migration max to validate precondition block")
|
|
}
|
|
|
|
rootDir := t.TempDir()
|
|
composeFile := filepath.Join(rootDir, "docker-compose.prod.yml")
|
|
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envFile := filepath.Join(rootDir, "bootstrap.env")
|
|
requiredValues := make([]string, 0, len(requiredChatOnlyInputs))
|
|
for key := range requiredChatOnlyInputs {
|
|
requiredValues = append(requiredValues, key+"=value-"+strings.ToLower(key))
|
|
}
|
|
if err := os.WriteFile(envFile, []byte(strings.Join(requiredValues, "\n")), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
inputs, err := discoverComposeInputsFromFile(composeFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
discovered, _, _, err := resolveDeploymentInputs(inputs, envFile, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
target, err := resolveDeploymentTarget("production")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target.ComposeFile = composeFile
|
|
plan := &deploymentPlan{
|
|
Version: 1,
|
|
PlanID: "migration-precondition",
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
|
|
Readiness: readinessReady,
|
|
ReadinessReasons: nil,
|
|
Target: "production",
|
|
ComposeFile: composeFile,
|
|
DockerContext: target.DockerContext,
|
|
ProjectName: target.ProjectName,
|
|
EnvFile: envFile,
|
|
SourceRevision: targetRevision,
|
|
SourceRevisionMode: "explicit",
|
|
ArtifactRevision: targetRevision,
|
|
DeploymentConfigRev: resolveConfigHash(composeFile),
|
|
CurrentProductionRev: currentProductionRevision,
|
|
KnownGoodRevision: "",
|
|
Classify: map[string][]string{},
|
|
Inputs: discovered,
|
|
KnownInputsSource: map[string]string{},
|
|
}
|
|
plan.Migrations, err = migrationState(root, targetRevision, currentProductionRevision)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !plan.Migrations.NeedKnownGood {
|
|
t.Fatal("expected migration state to require known good for this test")
|
|
}
|
|
plan.PlanBindingHash = buildPlanBindingFromInputs(
|
|
plan.Inputs,
|
|
plan.SourceRevision,
|
|
plan.ArtifactRevision,
|
|
plan.DeploymentConfigRev,
|
|
target.DockerContext,
|
|
target.Containers,
|
|
target.Volumes,
|
|
)
|
|
if err := assertPlanCurrent(plan); err == nil {
|
|
t.Fatal("expected assertPlanCurrent to reject migration precondition without known-good")
|
|
} else if !strings.Contains(err.Error(), "migration precondition failed") {
|
|
t.Fatalf("expected migration precondition failure, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeploymentPlanDoesNotLeakSecretValues(t *testing.T) {
|
|
root, err := gitRepositoryRoot(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target, err := resolveDeploymentTarget("production")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
composeFile := filepath.Join(t.TempDir(), "docker-compose.prod.yml")
|
|
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target.ComposeFile = composeFile
|
|
|
|
envFile := filepath.Join(t.TempDir(), "production.env")
|
|
if err := os.WriteFile(envFile, []byte(strings.Join([]string{
|
|
"POSTGRES_SUPERUSER_PASSWORD=super-secret-123",
|
|
"CREATOR_PASSWORD_FILE=creator-password-file-456",
|
|
"COMMIT_EXECUTOR_TOKEN=executor-token-789",
|
|
}, "\n")), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan, err := buildDeploymentPlan(root, target, envFile, "HEAD", 24, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payload, err := json.Marshal(plan)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
serialized := string(payload)
|
|
for _, secretValue := range []string{
|
|
"super-secret-123",
|
|
"creator-password-file-456",
|
|
"executor-token-789",
|
|
} {
|
|
if strings.Contains(serialized, secretValue) {
|
|
t.Fatalf("plain secret value leaked into plan: %q", secretValue)
|
|
}
|
|
}
|
|
if got := len(plan.SecretReferences); got < 3 {
|
|
t.Fatalf("expected at least 3 secret references in plan, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestDefaultKnownGoodManifestPathStableWithoutLocalAppData(t *testing.T) {
|
|
t.Setenv("LOCALAPPDATA", "")
|
|
t.Setenv("LOCALAPPDATA_ALT", "")
|
|
if strings.TrimSpace(defaultKnownGoodManifestPath()) != "" {
|
|
t.Fatal("expected empty path when LOCALAPPDATA is unset")
|
|
}
|
|
}
|
|
|
|
func TestDeployApplyRunsHealthGateWithReadyPlanAndSimulatedComposeDocker(t *testing.T) {
|
|
originalPath := os.Getenv("PATH")
|
|
fakeDockerDir := t.TempDir()
|
|
fakeDocker := filepath.Join(fakeDockerDir, "docker.bat")
|
|
dockerBatch := "@echo off\n" +
|
|
"if \"%~1\"==\"--context\" (\n" +
|
|
" if \"%~3\"==\"compose\" exit /B 0\n" +
|
|
" if \"%~3\"==\"inspect\" (\n" +
|
|
" if \"%~6\"==\"{{index .Config.Labels \\\"org.opencontainers.image.revision\\\"}}\" echo %FAKE_DOCKER_REVISION%\n" +
|
|
" if \"%~6\"==\"{{.State.Status}}\" echo running\n" +
|
|
" exit /B 0\n" +
|
|
" )\n" +
|
|
" if \"%~3\"==\"volume\" if \"%~4\"==\"inspect\" exit /B 0\n" +
|
|
")\n" +
|
|
"exit /B 1\n"
|
|
if err := os.WriteFile(fakeDocker, []byte(dockerBatch), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Setenv("PATH", fakeDockerDir+";"+originalPath)
|
|
defer os.Setenv("PATH", originalPath)
|
|
|
|
root, err := gitRepositoryRoot(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target, err := resolveDeploymentTarget("production")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
composeFile := filepath.Join(fakeDockerDir, "docker-compose.prod.yml")
|
|
target.ComposeFile = composeFile
|
|
if err := os.WriteFile(composeFile, []byte("services:\\n app:\\n image: alpine\\n environment:\\n - POSTGRES_SUPERUSER=${POSTGRES_SUPERUSER}\\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envFile := filepath.Join(fakeDockerDir, "production.env")
|
|
env := []string{
|
|
"POSTGRES_SUPERUSER=deploy-apply-test",
|
|
"POSTGRES_SUPERUSER_PASSWORD=pw-super",
|
|
"CONTINUITY_DB_PASSWORD=pw-cont",
|
|
"WORK_DB_PASSWORD=pw-work",
|
|
"CAPABILITY_DB_PASSWORD=pw-cap",
|
|
"COMMIT_DB_PASSWORD=pw-commit",
|
|
"AUTHORITY_DB_PASSWORD=pw-auth",
|
|
"IDENTITY_DB_PASSWORD=pw-id",
|
|
"SITUATION_DB_PASSWORD=pw-sit",
|
|
"CREATOR_USERNAME=creator",
|
|
"CREATOR_PASSWORD_FILE=creator-password",
|
|
"CREATOR_BOOTSTRAP_TOKEN=creator-bootstrap",
|
|
"CREATOR_SESSION_SIGNING_KEY=creator-session",
|
|
"COMMIT_EXECUTOR_TOKEN=commit-exec",
|
|
"EPISTEMICS_POSTGRES_CREDENTIAL_REF=epi-cred-ref",
|
|
"INTERACTION_POSTGRES_CREDENTIAL_REF=int-cred-ref",
|
|
"EPISTEMICS_POSTGRES_PASSWORD=epi-password",
|
|
"INTERACTION_POSTGRES_PASSWORD=int-password",
|
|
}
|
|
if err := os.WriteFile(envFile, []byte(strings.Join(env, "\n")), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
knownGood, _, err := captureKnownGood(target, "")
|
|
if err != nil || strings.TrimSpace(knownGood) == "" {
|
|
knownGood = "11a6a23f6e4f44dd88265a04846419a27f54df98"
|
|
}
|
|
plan, err := buildDeploymentPlan(root, target, envFile, knownGood, 24, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
writer.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
originalHealthEndpoints := append([]string{}, deploymentExternalHealthEndpoints...)
|
|
deploymentExternalHealthEndpoints = []string{server.URL + "/healthz", server.URL + "/chat"}
|
|
defer func() {
|
|
deploymentExternalHealthEndpoints = originalHealthEndpoints
|
|
}()
|
|
|
|
plan.PlanBindingHash = buildPlanBindingFromInputs(
|
|
plan.Inputs,
|
|
plan.SourceRevision,
|
|
plan.ArtifactRevision,
|
|
plan.DeploymentConfigRev,
|
|
target.DockerContext,
|
|
target.Containers,
|
|
target.Volumes,
|
|
)
|
|
planPath := filepath.Join(fakeDockerDir, "ready-plan.json")
|
|
payload, err := json.Marshal(plan)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(planPath, payload, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("FAKE_DOCKER_REVISION", plan.ArtifactRevision)
|
|
if err := deployApply([]string{"-plan", planPath}); err != nil {
|
|
t.Fatalf("expected successful apply with simulated docker and passing health checks, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeployApplyHealthGateRejectsOnFailedHTTPHealth(t *testing.T) {
|
|
originalPath := os.Getenv("PATH")
|
|
fakeDockerDir := t.TempDir()
|
|
fakeDocker := filepath.Join(fakeDockerDir, "docker.bat")
|
|
dockerBatch := "@echo off\n" +
|
|
"if \"%~1\"==\"--context\" (\n" +
|
|
" if \"%~3\"==\"compose\" exit /B 0\n" +
|
|
" if \"%~3\"==\"inspect\" (\n" +
|
|
" if \"%~6\"==\"{{index .Config.Labels \\\"org.opencontainers.image.revision\\\"}}\" echo %FAKE_DOCKER_REVISION%\n" +
|
|
" if \"%~6\"==\"{{.State.Status}}\" echo running\n" +
|
|
" exit /B 0\n" +
|
|
" )\n" +
|
|
" if \"%~3\"==\"volume\" if \"%~4\"==\"inspect\" exit /B 0\n" +
|
|
")\n" +
|
|
"exit /B 1\n"
|
|
if err := os.WriteFile(fakeDocker, []byte(dockerBatch), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Setenv("PATH", fakeDockerDir+";"+originalPath)
|
|
defer os.Setenv("PATH", originalPath)
|
|
|
|
root, err := gitRepositoryRoot(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target, err := resolveDeploymentTarget("production")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
composeFile := filepath.Join(fakeDockerDir, "docker-compose.prod.yml")
|
|
target.ComposeFile = composeFile
|
|
if err := os.WriteFile(composeFile, []byte("services:\\n app:\\n image: alpine\\n environment:\\n - POSTGRES_SUPERUSER=${POSTGRES_SUPERUSER}\\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envFile := filepath.Join(fakeDockerDir, "production.env")
|
|
env := []string{
|
|
"POSTGRES_SUPERUSER=deploy-apply-test",
|
|
"POSTGRES_SUPERUSER_PASSWORD=pw-super",
|
|
"CONTINUITY_DB_PASSWORD=pw-cont",
|
|
"WORK_DB_PASSWORD=pw-work",
|
|
"CAPABILITY_DB_PASSWORD=pw-cap",
|
|
"COMMIT_DB_PASSWORD=pw-commit",
|
|
"AUTHORITY_DB_PASSWORD=pw-auth",
|
|
"IDENTITY_DB_PASSWORD=pw-id",
|
|
"SITUATION_DB_PASSWORD=pw-sit",
|
|
"CREATOR_USERNAME=creator",
|
|
"CREATOR_PASSWORD_FILE=creator-password",
|
|
"CREATOR_BOOTSTRAP_TOKEN=creator-bootstrap",
|
|
"CREATOR_SESSION_SIGNING_KEY=creator-session",
|
|
"COMMIT_EXECUTOR_TOKEN=commit-exec",
|
|
"EPISTEMICS_POSTGRES_CREDENTIAL_REF=epi-cred-ref",
|
|
"INTERACTION_POSTGRES_CREDENTIAL_REF=int-cred-ref",
|
|
"EPISTEMICS_POSTGRES_PASSWORD=epi-password",
|
|
"INTERACTION_POSTGRES_PASSWORD=int-password",
|
|
}
|
|
if err := os.WriteFile(envFile, []byte(strings.Join(env, "\n")), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
knownGood, _, err := captureKnownGood(target, "")
|
|
if err != nil || strings.TrimSpace(knownGood) == "" {
|
|
knownGood = "11a6a23f6e4f44dd88265a04846419a27f54df98"
|
|
}
|
|
plan, err := buildDeploymentPlan(root, target, envFile, knownGood, 24, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
plan.PlanBindingHash = buildPlanBindingFromInputs(
|
|
plan.Inputs,
|
|
plan.SourceRevision,
|
|
plan.ArtifactRevision,
|
|
plan.DeploymentConfigRev,
|
|
target.DockerContext,
|
|
target.Containers,
|
|
target.Volumes,
|
|
)
|
|
planPath := filepath.Join(fakeDockerDir, "ready-plan-bad-health.json")
|
|
payload, err := json.Marshal(plan)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(planPath, payload, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("FAKE_DOCKER_REVISION", plan.ArtifactRevision)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
switch request.URL.Path {
|
|
case "/healthz":
|
|
writer.WriteHeader(http.StatusInternalServerError)
|
|
default:
|
|
writer.WriteHeader(http.StatusOK)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
originalHealthEndpoints := append([]string{}, deploymentExternalHealthEndpoints...)
|
|
deploymentExternalHealthEndpoints = []string{server.URL + "/healthz", server.URL + "/chat"}
|
|
defer func() {
|
|
deploymentExternalHealthEndpoints = originalHealthEndpoints
|
|
}()
|
|
|
|
if err := deployApply([]string{"-plan", planPath}); err == nil {
|
|
t.Fatalf("expected apply to fail when health check fails")
|
|
}
|
|
}
|