feat: make infra provisioning idempotent + aeries-daeya public discovery feed
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:
jordan 2026-02-28 17:32:21 -07:00
parent a9fd431db9
commit 32d50a6952
9 changed files with 188 additions and 100 deletions

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"]

View File

@ -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'

View File

@ -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}

View File

@ -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
} }
} }

View File

@ -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",