feat: make infra provisioning idempotent + aeries-daeya public discovery feed
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Make postgres and redis provisioning idempotent: return success when already provisioned with credentials stored, allowing cookbook trees to safely include explicit add-db/add-redis steps alongside auto-provisioned project creation - Update tests to reflect new idempotent behavior - Consolidate docs CI into single multi-stage Docker build (remove separate build-docs step; Dockerfile.nginx now builds Slate then serves with nginx) - Delete redundant skeleton docs/Dockerfile (replaced by multi-stage nginx image) - Add watch verb to woodpecker-deployer RBAC (required by kubectl rollout status) - Aeries Daeya cookbook: add public discovery feed (/) + character profiles (/c/:handle), characters.published/handle/tagline fields, dark pink design system, /studio/* routes, verify-public-discovery + verify-otp-endpoint smoke test steps - Fix Input.tsx: remove non-existent --border-hover CSS variable hover effect
This commit is contained in:
parent
a9fd431db9
commit
32d50a6952
@ -47,29 +47,32 @@ run_flow() {
|
|||||||
echo "Running tree: aeries-daeya for project: $PROJECT_NAME"
|
echo "Running tree: aeries-daeya for project: $PROJECT_NAME"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Phase 1 — Infrastructure (DB + Redis + service + worker + app)"
|
echo " Phase 1 — Infrastructure (DB + Redis + service + worker + app)"
|
||||||
echo " Phase 2 — Avatar & Look Data Model (characters, looks, albums, posts, mutations)"
|
echo " Phase 2 — Avatar & Look Data Model (characters + public discovery endpoints)"
|
||||||
echo " Phase 3 — AI Generation Pipeline (portrait gen, outfit styling, mutation explorer)"
|
echo " Phase 3 — AI Generation Pipeline (portrait gen, outfit styling, mutation explorer)"
|
||||||
echo " Phase 4 — Studio UI (creation wizard, mutation explorer, look panel, albums)"
|
echo " Phase 4 — Studio UI (public feed + character profiles + studio + dark pink theme)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
"$SCRIPT_DIR/tree-runner.sh" run aeries-daeya \
|
"$SCRIPT_DIR/tree-runner.sh" run aeries-daeya \
|
||||||
--project-name "$PROJECT_NAME" \
|
--project-name "$PROJECT_NAME" \
|
||||||
${AUTO_TEARDOWN:+--auto-teardown}
|
$([[ "$AUTO_TEARDOWN" == "true" ]] && echo "--auto-teardown")
|
||||||
|
|
||||||
DOMAIN=$(api_call GET "/project/$PROJECT_NAME" | jq -r '.data.domain // empty')
|
DOMAIN=$(api_call GET "/project/$PROJECT_NAME" | jq -r '.data.domain // empty')
|
||||||
|
|
||||||
if [[ -n "$DOMAIN" ]]; then
|
if [[ -n "$DOMAIN" ]]; then
|
||||||
print_success "Aeries Daeya is live at https://$DOMAIN"
|
print_success "Aeries Daeya is live at https://$DOMAIN"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Studio: https://$DOMAIN"
|
echo " Discovery: https://$DOMAIN (public — no login)"
|
||||||
|
echo " Studio: https://$DOMAIN/studio (requires login)"
|
||||||
echo " API health: https://$DOMAIN/api/daeya-api/health"
|
echo " API health: https://$DOMAIN/api/daeya-api/health"
|
||||||
echo " Characters: https://$DOMAIN/api/daeya-api/characters (requires auth)"
|
echo " Public feed: https://$DOMAIN/api/daeya-api/characters/public"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Flow:"
|
echo " Flow:"
|
||||||
echo " 1. Open https://$DOMAIN → login with OTP"
|
echo " 1. Open https://$DOMAIN → browse published characters (no login needed)"
|
||||||
echo " 2. Create Character → 4-step wizard (describe, shape, soul, generate)"
|
echo " 2. Click 'Create Your Character' → OTP login → /studio"
|
||||||
echo " 3. Open Character → use Mutation Explorer to adjust skin tone, background, lighting"
|
echo " 3. Create Character → 4-step wizard (describe, shape, soul, generate)"
|
||||||
echo " 4. Add Look → upload a photo of an outfit to style on your character"
|
echo " 4. In studio: toggle 'Public' → character appears on discovery feed"
|
||||||
|
echo " 5. Open Character → Mutation Explorer (skin tone, background, lighting, style)"
|
||||||
|
echo " 6. Add Look → upload a photo of an outfit to style on your character"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +117,18 @@ diagnose() {
|
|||||||
diagnose_site_failure "$domain" "$PROJECT_NAME"
|
diagnose_site_failure "$domain" "$PROJECT_NAME"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
print_diagnostic_header "Notify / Email Checks"
|
||||||
|
echo ""
|
||||||
|
echo " If OTP email never arrives:"
|
||||||
|
print_fix "Check NOTIFY_API_KEY is injected into the daeya-api pod"
|
||||||
|
print_cmd "kubectl exec -n projects deploy/$PROJECT_NAME-daeya-api -- env | grep NOTIFY"
|
||||||
|
echo ""
|
||||||
|
print_fix "If NOTIFY_API_KEY is empty, notify provisioning failed during project creation"
|
||||||
|
print_cmd "curl -s -H 'X-API-Key: \$RDEV_API_KEY' \$RDEV_API_URL/projects/$PROJECT_NAME/notify/status | jq"
|
||||||
|
echo ""
|
||||||
|
print_fix "Test OTP endpoint directly (200/202/429 = notify working; 404/500 = broken)"
|
||||||
|
print_cmd "curl -s -o /dev/null -w '%{http_code}' -X POST https://$domain/api/daeya-api/auth/otp/send -H 'Content-Type: application/json' -d '{\"email\":\"test@example.com\"}'"
|
||||||
|
|
||||||
print_diagnostic_header "AI Generation Checks"
|
print_diagnostic_header "AI Generation Checks"
|
||||||
echo ""
|
echo ""
|
||||||
echo " If characters are stuck in 'pending' status:"
|
echo " If characters are stuck in 'pending' status:"
|
||||||
@ -127,6 +142,16 @@ diagnose() {
|
|||||||
echo " If look generation fails with photo upload:"
|
echo " If look generation fails with photo upload:"
|
||||||
print_fix "GEMINI_API_KEY may not be injected or vision endpoint unreachable"
|
print_fix "GEMINI_API_KEY may not be injected or vision endpoint unreachable"
|
||||||
print_cmd "kubectl exec -n projects deploy/$PROJECT_NAME-media-worker -- env | grep GEMINI"
|
print_cmd "kubectl exec -n projects deploy/$PROJECT_NAME-media-worker -- env | grep GEMINI"
|
||||||
|
|
||||||
|
print_diagnostic_header "Public Discovery Checks"
|
||||||
|
echo ""
|
||||||
|
echo " If /characters/public returns 404:"
|
||||||
|
print_fix "implement-avatar-model build may not have created public endpoints"
|
||||||
|
print_cmd "curl -s https://$domain/api/daeya-api/characters/public | jq"
|
||||||
|
echo ""
|
||||||
|
echo " If published characters don't show on the discovery feed:"
|
||||||
|
print_fix "Character may not be toggled to published=true in the studio"
|
||||||
|
print_cmd "curl -s -H 'Authorization: Bearer <token>' https://$domain/api/daeya-api/characters | jq '.[].published'"
|
||||||
}
|
}
|
||||||
|
|
||||||
teardown() {
|
teardown() {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -22,15 +22,19 @@ metadata:
|
|||||||
app.kubernetes.io/part-of: rdev
|
app.kubernetes.io/part-of: rdev
|
||||||
rules:
|
rules:
|
||||||
# Deploy steps: set image, patch replicas, verify rollout
|
# Deploy steps: set image, patch replicas, verify rollout
|
||||||
# - get/list: read deployment and replicaset state
|
# - get/list/watch: read deployment and replicaset state (watch required by kubectl rollout status)
|
||||||
# - patch: kubectl set image, kubectl patch (replicas)
|
# - patch: kubectl set image, kubectl patch (replicas)
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["deployments"]
|
resources: ["deployments"]
|
||||||
verbs: ["get", "list", "patch"]
|
verbs: ["get", "list", "patch", "watch"]
|
||||||
# rollout status needs to watch replicasets
|
# rollout status watches replicasets to track new/old replica counts
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["replicasets"]
|
resources: ["replicasets"]
|
||||||
verbs: ["get", "list"]
|
verbs: ["get", "list", "watch"]
|
||||||
|
# rollout status watches pods to detect readiness and crash loops
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
|
|||||||
@ -143,29 +143,11 @@ steps:
|
|||||||
branch: main
|
branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
# Build Slate static documentation (skipped if no docs infrastructure)
|
# Build and push docs-nginx image (multi-stage: builds Slate + serves with nginx)
|
||||||
build-docs:
|
# Depends on generate-docs which produces markdown includes from OpenAPI specs
|
||||||
image: ruby:3.2-slim
|
# failure: ignore allows pipeline to continue if docs build fails
|
||||||
depends_on: [generate-docs]
|
|
||||||
failure: ignore
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ ! -d "docs" ] || [ ! -f "docs/Gemfile" ]; then
|
|
||||||
echo "==> No docs/ directory or Gemfile found, skipping Slate build"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
- apt-get update && apt-get install -y build-essential nodejs
|
|
||||||
- cd docs && bundle install --jobs 4
|
|
||||||
- cd docs && bundle exec middleman build --clean
|
|
||||||
- echo "==> Docs built to docs/build/"
|
|
||||||
when:
|
|
||||||
branch: main
|
|
||||||
event: push
|
|
||||||
|
|
||||||
# Build and push docs-nginx image (skipped if no docs build output)
|
|
||||||
# failure: ignore allows pipeline to continue if docs weren't built
|
|
||||||
build-docs-image:
|
build-docs-image:
|
||||||
depends_on: [build-docs]
|
depends_on: [generate-docs]
|
||||||
image: woodpeckerci/plugin-kaniko
|
image: woodpeckerci/plugin-kaniko
|
||||||
failure: ignore
|
failure: ignore
|
||||||
settings:
|
settings:
|
||||||
@ -194,16 +176,8 @@ steps:
|
|||||||
REPO="{{PROJECT_NAME}}-docs"
|
REPO="{{PROJECT_NAME}}-docs"
|
||||||
REGISTRY="registry.threesix.ai"
|
REGISTRY="registry.threesix.ai"
|
||||||
|
|
||||||
# Check if docs were built (same check as deploy-docs)
|
|
||||||
if [ ! -d "docs/build" ]; then
|
|
||||||
echo "==> No docs build output, skipping verification"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
|
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
|
||||||
|
|
||||||
# Query registry v2 API to check if manifest exists
|
|
||||||
# Returns 200 if image exists, 404 if not
|
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
--insecure \
|
--insecure \
|
||||||
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
|
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
|
||||||
@ -211,13 +185,10 @@ steps:
|
|||||||
|
|
||||||
if [ "$HTTP_CODE" = "200" ]; then
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
|
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
|
||||||
# Create marker file for deploy-docs to check
|
|
||||||
touch /tmp/image-verified
|
|
||||||
exit 0
|
exit 0
|
||||||
elif [ "$HTTP_CODE" = "404" ]; then
|
elif [ "$HTTP_CODE" = "404" ]; then
|
||||||
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
|
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
|
||||||
echo " This may indicate the build step failed or is still pushing"
|
echo " Build step may have failed. Deploy will be skipped."
|
||||||
echo " Deploy step will be skipped to prevent ImagePullBackOff"
|
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
|
echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
# Slate documentation builder
|
|
||||||
# Used by CI to generate static HTML from OpenAPI specs
|
|
||||||
|
|
||||||
FROM ruby:3.2-slim
|
|
||||||
|
|
||||||
# Install build dependencies
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
build-essential \
|
|
||||||
nodejs \
|
|
||||||
npm \
|
|
||||||
git \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install widdershins globally for OpenAPI to Slate markdown conversion
|
|
||||||
RUN npm install -g widdershins
|
|
||||||
|
|
||||||
WORKDIR /docs
|
|
||||||
|
|
||||||
# Copy Gemfile first for layer caching
|
|
||||||
COPY Gemfile Gemfile.lock* ./
|
|
||||||
RUN bundle install
|
|
||||||
|
|
||||||
# Copy the rest of the docs source
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build static site
|
|
||||||
CMD ["bundle", "exec", "middleman", "build", "--clean"]
|
|
||||||
@ -1,13 +1,35 @@
|
|||||||
# Production nginx image for serving Slate documentation
|
# Production nginx image for serving Slate documentation
|
||||||
# Built by CI after Slate generates static HTML
|
# Multi-stage build: generates static HTML then serves with nginx
|
||||||
|
# No dependency on external build steps — self-contained
|
||||||
|
|
||||||
|
# Stage 1: Build Slate static site
|
||||||
|
FROM ruby:3.2-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /docs
|
||||||
|
|
||||||
|
# Copy Gemfile first for layer caching
|
||||||
|
COPY Gemfile Gemfile.lock* ./
|
||||||
|
RUN bundle install --jobs 4
|
||||||
|
|
||||||
|
# Copy the rest of the docs source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build static site — produces build/ directory
|
||||||
|
RUN bundle exec middleman build --clean
|
||||||
|
|
||||||
|
# Stage 2: Serve with nginx
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
# Remove default nginx content
|
# Remove default nginx content
|
||||||
RUN rm -rf /usr/share/nginx/html/*
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
|
||||||
# Copy built static files from Slate
|
# Copy built static files from Slate builder
|
||||||
COPY build/ /usr/share/nginx/html/
|
COPY --from=builder /docs/build/ /usr/share/nginx/html/
|
||||||
|
|
||||||
# Custom nginx config for SPA-style routing
|
# Custom nginx config for SPA-style routing
|
||||||
RUN cat > /etc/nginx/conf.d/default.conf << 'EOF'
|
RUN cat > /etc/nginx/conf.d/default.conf << 'EOF'
|
||||||
|
|||||||
@ -14,7 +14,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[var(--text-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[var(--text-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
error
|
error
|
||||||
? 'border-[var(--error)] focus-visible:ring-[var(--error)]'
|
? 'border-[var(--error)] focus-visible:ring-[var(--error)]'
|
||||||
: 'border-[var(--border)] hover:border-[var(--border-hover)]',
|
: 'border-[var(--border)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ func (s *ComponentService) addInfraComponent(ctx context.Context, projectID stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// provisionPostgres provisions a PostgreSQL/CockroachDB database for the project.
|
// provisionPostgres provisions a PostgreSQL/CockroachDB database for the project.
|
||||||
|
// Idempotent: returns existing component if already provisioned with credentials stored.
|
||||||
func (s *ComponentService) provisionPostgres(ctx context.Context, projectID, name string) (*domain.Component, error) {
|
func (s *ComponentService) provisionPostgres(ctx context.Context, projectID, name string) (*domain.Component, error) {
|
||||||
if s.dbProvisioner == nil {
|
if s.dbProvisioner == nil {
|
||||||
return nil, fmt.Errorf("database provisioner not configured")
|
return nil, fmt.Errorf("database provisioner not configured")
|
||||||
@ -35,7 +36,41 @@ func (s *ComponentService) provisionPostgres(ctx context.Context, projectID, nam
|
|||||||
return nil, fmt.Errorf("failed to check existing database: %w", err)
|
return nil, fmt.Errorf("failed to check existing database: %w", err)
|
||||||
}
|
}
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
return nil, fmt.Errorf("%w: postgres already provisioned for project %s", domain.ErrDuplicateComponent, projectID)
|
// Already provisioned — return success if credentials are stored (idempotent).
|
||||||
|
// This allows cookbook trees to have explicit add-db steps even though
|
||||||
|
// project creation auto-provisions the database.
|
||||||
|
if s.credentialStore != nil {
|
||||||
|
storedURL, storeErr := s.credentialStore.Get(ctx, projectID+":DATABASE_URL")
|
||||||
|
if storeErr == nil && storedURL != "" {
|
||||||
|
log := logging.FromContext(ctx).WithService("component")
|
||||||
|
log.Info("postgres already provisioned, returning existing (idempotent)",
|
||||||
|
logging.FieldProjectID, projectID)
|
||||||
|
return &domain.Component{
|
||||||
|
Type: domain.ComponentTypePostgres,
|
||||||
|
Name: name,
|
||||||
|
Path: "infra/postgres",
|
||||||
|
Port: existing.Port,
|
||||||
|
Template: "postgres",
|
||||||
|
Dependencies: []string{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
// Credentials missing — fall through to re-provision
|
||||||
|
log := logging.FromContext(ctx).WithService("component")
|
||||||
|
log.Warn("database exists but DATABASE_URL not in credential store, re-provisioning",
|
||||||
|
logging.FieldProjectID, projectID)
|
||||||
|
} else {
|
||||||
|
log := logging.FromContext(ctx).WithService("component")
|
||||||
|
log.Info("postgres already provisioned, returning existing (idempotent)",
|
||||||
|
logging.FieldProjectID, projectID)
|
||||||
|
return &domain.Component{
|
||||||
|
Type: domain.ComponentTypePostgres,
|
||||||
|
Name: name,
|
||||||
|
Path: "infra/postgres",
|
||||||
|
Port: existing.Port,
|
||||||
|
Template: "postgres",
|
||||||
|
Dependencies: []string{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provision the database
|
// Provision the database
|
||||||
@ -88,19 +123,41 @@ func (s *ComponentService) provisionRedis(ctx context.Context, projectID, name s
|
|||||||
return nil, fmt.Errorf("failed to check existing cache: %w", err)
|
return nil, fmt.Errorf("failed to check existing cache: %w", err)
|
||||||
}
|
}
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
// Redis user exists — check if credentials are stored. If they are, it's a true duplicate.
|
// Redis user exists — check if credentials are stored.
|
||||||
// If not (credentials were lost), fall through to re-provision (CreateProjectCache resets the password).
|
// If they are, return success (idempotent) so cookbook trees can safely
|
||||||
|
// have add-redis steps even though project creation auto-provisions Redis.
|
||||||
|
// If not (credentials were lost), fall through to re-provision.
|
||||||
if s.credentialStore != nil {
|
if s.credentialStore != nil {
|
||||||
storedURL, storeErr := s.credentialStore.Get(ctx, projectID+":REDIS_URL")
|
storedURL, storeErr := s.credentialStore.Get(ctx, projectID+":REDIS_URL")
|
||||||
if storeErr == nil && storedURL != "" {
|
if storeErr == nil && storedURL != "" {
|
||||||
return nil, fmt.Errorf("%w: redis already provisioned for project %s", domain.ErrDuplicateComponent, projectID)
|
log := logging.FromContext(ctx).WithService("component")
|
||||||
|
log.Info("redis already provisioned, returning existing (idempotent)",
|
||||||
|
logging.FieldProjectID, projectID)
|
||||||
|
return &domain.Component{
|
||||||
|
Type: domain.ComponentTypeRedis,
|
||||||
|
Name: name,
|
||||||
|
Path: "infra/redis",
|
||||||
|
Port: existing.Port,
|
||||||
|
Template: "redis",
|
||||||
|
Dependencies: []string{},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
// Credentials missing from store — re-provision to recover
|
// Credentials missing from store — re-provision to recover
|
||||||
log := logging.FromContext(ctx).WithService("component")
|
log := logging.FromContext(ctx).WithService("component")
|
||||||
log.Warn("redis user exists but REDIS_URL not in credential store, re-provisioning",
|
log.Warn("redis user exists but REDIS_URL not in credential store, re-provisioning",
|
||||||
logging.FieldProjectID, projectID)
|
logging.FieldProjectID, projectID)
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("%w: redis already provisioned for project %s", domain.ErrDuplicateComponent, projectID)
|
log := logging.FromContext(ctx).WithService("component")
|
||||||
|
log.Info("redis already provisioned, returning existing (idempotent)",
|
||||||
|
logging.FieldProjectID, projectID)
|
||||||
|
return &domain.Component{
|
||||||
|
Type: domain.ComponentTypeRedis,
|
||||||
|
Name: name,
|
||||||
|
Path: "infra/redis",
|
||||||
|
Port: existing.Port,
|
||||||
|
Template: "redis",
|
||||||
|
Dependencies: []string{},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -283,15 +283,18 @@ func TestProvisionPostgres(t *testing.T) {
|
|||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "postgres already exists",
|
name: "postgres already exists with credentials (idempotent)",
|
||||||
projectID: "test-project",
|
projectID: "test-project",
|
||||||
componentName: "main-db",
|
componentName: "main-db",
|
||||||
dbProvisioner: &mockDatabaseProvisioner{
|
dbProvisioner: &mockDatabaseProvisioner{
|
||||||
existingDB: &domain.DatabaseCredentials{ProjectID: "test-project"},
|
existingDB: &domain.DatabaseCredentials{ProjectID: "test-project", Port: 26257},
|
||||||
},
|
},
|
||||||
credStore: newMockCredentialStore(),
|
credStore: func() *mockCredentialStore {
|
||||||
wantErr: true,
|
cs := newMockCredentialStore()
|
||||||
wantErrContains: "already provisioned",
|
cs.stored["test-project:DATABASE_URL"] = "postgresql://proj-test-project:pass@localhost:26257/project_test_project"
|
||||||
|
return cs
|
||||||
|
}(),
|
||||||
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "provisioning fails",
|
name: "provisioning fails",
|
||||||
@ -369,20 +372,19 @@ func TestProvisionRedis(t *testing.T) {
|
|||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "redis already exists",
|
name: "redis already exists with credentials (idempotent)",
|
||||||
projectID: "test-project",
|
projectID: "test-project",
|
||||||
componentName: "cache",
|
componentName: "cache",
|
||||||
cacheProvisioner: &mockCacheProvisioner{
|
cacheProvisioner: &mockCacheProvisioner{
|
||||||
existingCache: &domain.CacheCredentials{ProjectID: "test-project"},
|
existingCache: &domain.CacheCredentials{ProjectID: "test-project", Port: 6379},
|
||||||
},
|
},
|
||||||
credStore: func() *mockCredentialStore {
|
credStore: func() *mockCredentialStore {
|
||||||
// Simulate credentials already stored — this is a true duplicate
|
// Simulate credentials already stored — idempotent return
|
||||||
cs := newMockCredentialStore()
|
cs := newMockCredentialStore()
|
||||||
cs.stored["test-project:REDIS_URL"] = "redis://proj-test-project:pass@localhost:6379"
|
cs.stored["test-project:REDIS_URL"] = "redis://proj-test-project:pass@localhost:6379"
|
||||||
return cs
|
return cs
|
||||||
}(),
|
}(),
|
||||||
wantErr: true,
|
wantErr: false,
|
||||||
wantErrContains: "already provisioned",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "provisioning fails",
|
name: "provisioning fails",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user