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 ""
|
||||
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 4 — Studio UI (creation wizard, mutation explorer, look panel, albums)"
|
||||
echo " Phase 4 — Studio UI (public feed + character profiles + studio + dark pink theme)"
|
||||
echo ""
|
||||
|
||||
"$SCRIPT_DIR/tree-runner.sh" run aeries-daeya \
|
||||
--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')
|
||||
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
print_success "Aeries Daeya is live at https://$DOMAIN"
|
||||
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 " Characters: https://$DOMAIN/api/daeya-api/characters (requires auth)"
|
||||
echo " Public feed: https://$DOMAIN/api/daeya-api/characters/public"
|
||||
echo ""
|
||||
echo " Flow:"
|
||||
echo " 1. Open https://$DOMAIN → login with OTP"
|
||||
echo " 2. Create Character → 4-step wizard (describe, shape, soul, generate)"
|
||||
echo " 3. Open Character → use Mutation Explorer to adjust skin tone, background, lighting"
|
||||
echo " 4. Add Look → upload a photo of an outfit to style on your character"
|
||||
echo " 1. Open https://$DOMAIN → browse published characters (no login needed)"
|
||||
echo " 2. Click 'Create Your Character' → OTP login → /studio"
|
||||
echo " 3. Create Character → 4-step wizard (describe, shape, soul, generate)"
|
||||
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
|
||||
}
|
||||
|
||||
@ -114,6 +117,18 @@ diagnose() {
|
||||
diagnose_site_failure "$domain" "$PROJECT_NAME"
|
||||
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"
|
||||
echo ""
|
||||
echo " If characters are stuck in 'pending' status:"
|
||||
@ -127,6 +142,16 @@ diagnose() {
|
||||
echo " If look generation fails with photo upload:"
|
||||
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_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() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -22,15 +22,19 @@ metadata:
|
||||
app.kubernetes.io/part-of: rdev
|
||||
rules:
|
||||
# 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)
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
verbs: ["get", "list", "patch"]
|
||||
# rollout status needs to watch replicasets
|
||||
verbs: ["get", "list", "patch", "watch"]
|
||||
# rollout status watches replicasets to track new/old replica counts
|
||||
- apiGroups: ["apps"]
|
||||
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
|
||||
kind: RoleBinding
|
||||
|
||||
@ -143,29 +143,11 @@ steps:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
# Build Slate static documentation (skipped if no docs infrastructure)
|
||||
build-docs:
|
||||
image: ruby:3.2-slim
|
||||
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 and push docs-nginx image (multi-stage: builds Slate + serves with nginx)
|
||||
# Depends on generate-docs which produces markdown includes from OpenAPI specs
|
||||
# failure: ignore allows pipeline to continue if docs build fails
|
||||
build-docs-image:
|
||||
depends_on: [build-docs]
|
||||
depends_on: [generate-docs]
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
failure: ignore
|
||||
settings:
|
||||
@ -194,16 +176,8 @@ steps:
|
||||
REPO="{{PROJECT_NAME}}-docs"
|
||||
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"
|
||||
|
||||
# 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}" \
|
||||
--insecure \
|
||||
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
|
||||
@ -211,13 +185,10 @@ steps:
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
|
||||
# Create marker file for deploy-docs to check
|
||||
touch /tmp/image-verified
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "404" ]; then
|
||||
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
|
||||
echo " This may indicate the build step failed or is still pushing"
|
||||
echo " Deploy step will be skipped to prevent ImagePullBackOff"
|
||||
echo " Build step may have failed. Deploy will be skipped."
|
||||
exit 1
|
||||
else
|
||||
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
|
||||
# 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
|
||||
|
||||
# Remove default nginx content
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy built static files from Slate
|
||||
COPY build/ /usr/share/nginx/html/
|
||||
# Copy built static files from Slate builder
|
||||
COPY --from=builder /docs/build/ /usr/share/nginx/html/
|
||||
|
||||
# Custom nginx config for SPA-style routing
|
||||
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',
|
||||
error
|
||||
? 'border-[var(--error)] focus-visible:ring-[var(--error)]'
|
||||
: 'border-[var(--border)] hover:border-[var(--border-hover)]',
|
||||
: 'border-[var(--border)]',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -24,6 +24,7 @@ func (s *ComponentService) addInfraComponent(ctx context.Context, projectID stri
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if s.dbProvisioner == nil {
|
||||
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)
|
||||
}
|
||||
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
|
||||
@ -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)
|
||||
}
|
||||
if existing != nil {
|
||||
// Redis user exists — check if credentials are stored. If they are, it's a true duplicate.
|
||||
// If not (credentials were lost), fall through to re-provision (CreateProjectCache resets the password).
|
||||
// Redis user exists — check if credentials are stored.
|
||||
// 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 {
|
||||
storedURL, storeErr := s.credentialStore.Get(ctx, projectID+":REDIS_URL")
|
||||
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
|
||||
log := logging.FromContext(ctx).WithService("component")
|
||||
log.Warn("redis user exists but REDIS_URL not in credential store, re-provisioning",
|
||||
logging.FieldProjectID, projectID)
|
||||
} 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,
|
||||
},
|
||||
{
|
||||
name: "postgres already exists",
|
||||
name: "postgres already exists with credentials (idempotent)",
|
||||
projectID: "test-project",
|
||||
componentName: "main-db",
|
||||
dbProvisioner: &mockDatabaseProvisioner{
|
||||
existingDB: &domain.DatabaseCredentials{ProjectID: "test-project"},
|
||||
existingDB: &domain.DatabaseCredentials{ProjectID: "test-project", Port: 26257},
|
||||
},
|
||||
credStore: newMockCredentialStore(),
|
||||
wantErr: true,
|
||||
wantErrContains: "already provisioned",
|
||||
credStore: func() *mockCredentialStore {
|
||||
cs := newMockCredentialStore()
|
||||
cs.stored["test-project:DATABASE_URL"] = "postgresql://proj-test-project:pass@localhost:26257/project_test_project"
|
||||
return cs
|
||||
}(),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "provisioning fails",
|
||||
@ -369,20 +372,19 @@ func TestProvisionRedis(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "redis already exists",
|
||||
name: "redis already exists with credentials (idempotent)",
|
||||
projectID: "test-project",
|
||||
componentName: "cache",
|
||||
cacheProvisioner: &mockCacheProvisioner{
|
||||
existingCache: &domain.CacheCredentials{ProjectID: "test-project"},
|
||||
existingCache: &domain.CacheCredentials{ProjectID: "test-project", Port: 6379},
|
||||
},
|
||||
credStore: func() *mockCredentialStore {
|
||||
// Simulate credentials already stored — this is a true duplicate
|
||||
// Simulate credentials already stored — idempotent return
|
||||
cs := newMockCredentialStore()
|
||||
cs.stored["test-project:REDIS_URL"] = "redis://proj-test-project:pass@localhost:6379"
|
||||
return cs
|
||||
}(),
|
||||
wantErr: true,
|
||||
wantErrContains: "already provisioned",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "provisioning fails",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user