/** * WPML compatibility functions * * @global array $duplicated_posts Array to store the posts being duplicated. * * @package Yoast\WP\Duplicate_Post * @since 3.2 */ add_action( 'admin_init', 'duplicate_post_wpml_init' ); /** * Add handlers for WPML compatibility. */ function duplicate_post_wpml_init() { if ( defined( 'ICL_SITEPRESS_VERSION' ) ) { add_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10, 3 ); add_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10, 3 ); add_action( 'shutdown', 'duplicate_wpml_string_packages', 11 ); } } global $duplicated_posts; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: Renaming a global variable is a BC break. $duplicated_posts = []; /** * Copy post translations. * * @global SitePress $sitepress Instance of the Main WPML class. * @global array $duplicated_posts Array of duplicated posts. * * @param int $post_id ID of the copy. * @param WP_Post $post Original post object. * @param string $status Status of the new post. */ function duplicate_post_wpml_copy_translations( $post_id, $post, $status = '' ) { global $sitepress; global $duplicated_posts; remove_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10 ); remove_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10 ); $current_language = $sitepress->get_current_language(); $trid = $sitepress->get_element_trid( $post->ID ); if ( ! empty( $trid ) ) { $translations = $sitepress->get_element_translations( $trid ); $new_trid = $sitepress->get_element_trid( $post_id ); foreach ( $translations as $code => $details ) { if ( $code !== $current_language ) { if ( $details->element_id ) { $translation = get_post( $details->element_id ); if ( ! $translation ) { continue; } $new_post_id = duplicate_post_create_duplicate( $translation, $status ); if ( ! is_wp_error( $new_post_id ) ) { $sitepress->set_element_language_details( $new_post_id, 'post_' . $translation->post_type, $new_trid, $code, $current_language ); } } } } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: see above. $duplicated_posts[ $post->ID ] = $post_id; } } /** * Duplicate string packages. * * @global array() $duplicated_posts Array of duplicated posts. */ function duplicate_wpml_string_packages() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: renaming the function would be a BC-break. global $duplicated_posts; foreach ( $duplicated_posts as $original_post_id => $duplicate_post_id ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. $original_string_packages = apply_filters( 'wpml_st_get_post_string_packages', false, $original_post_id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. $new_string_packages = apply_filters( 'wpml_st_get_post_string_packages', false, $duplicate_post_id ); if ( is_array( $original_string_packages ) ) { foreach ( $original_string_packages as $original_string_package ) { $translated_original_strings = $original_string_package->get_translated_strings( [] ); foreach ( $new_string_packages as $new_string_package ) { $cache = new WPML_WP_Cache( 'WPML_Package' ); $cache->flush_group_cache(); $new_strings = $new_string_package->get_package_strings(); foreach ( $new_strings as $new_string ) { if ( isset( $translated_original_strings[ $new_string->name ] ) ) { foreach ( $translated_original_strings[ $new_string->name ] as $language => $translated_string ) { do_action( // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. 'wpml_add_string_translation', $new_string->id, $language, $translated_string['value'], $translated_string['status'] ); } } } } } } } } Re:ファイルフォルダ – Raqqa

Re:ファイルフォルダ

一気にフォルダファイル作成

api/
├── Dockerfile
├── requirements.txt
├── chromadb.yaml                 #(※運用上は k8s/infra へ移動も検討可)
└── app/
    ├── main.py
    ├── config.py
    ├── db.py                     # SQLAlchemy接続・reflect
    ├── routers/
    │   ├── portal_model.py       # /model
    │   ├── portal_field.py       # /field
    │   ├── portal_view.py        # /view
    │   ├── portal_smart_button.py# /smart_button
    │   ├── portal_tab.py         # /tab
    │   ├── portal_menu.py        # /menu
    │   ├── translate.py          # /translate (共通翻訳)
    │   ├── chroma.py             # /chroma    (共通エクスポート/検索)
    │   └── import.py             # /import    (共通インポート)
    ├── schemas/
    │   ├── portal_model.py       # Pydantic: PortalModel
    │   ├── portal_field.py       #           PortalField
    │   ├── portal_view.py        #           PortalView
    │   ├── portal_smart_button.py#           PortalSmartButton
    │   ├── portal_tab.py         #           PortalTab
    │   └── portal_menu.py        #           PortalMenu
    └── services/
        ├── i18n.py               # JA→EN 翻訳(非破壊・再帰)
        ├── embeddings.py
        ├── chroma_client.py
        ├── chroma_export.py      # 各エンティティ→Chromaコレクション
        ├── portal_import.py      # 共通CSV/JSON取込
        ├── view_export.py        # ※単数形
        └── view_import.py        # ※単数形

k8s/
├─ base/
│  ├─ namespace.yaml
│  ├─ kustomization.yaml
│  ├─ postgres/
│  │  ├─ statefulset.yaml
│  │  ├─ service.yaml
│  │  ├─ init-configmap.yaml
│  │  └─ auth-secret.yaml            # 実運用では overlays 側で生成推奨
│  ├─ odoo/
│  │  ├─ deployment.yaml
│  │  ├─ service.yaml
│  │  └─ ingress.yaml
│  ├─ portal-api/
│  │  ├─ deployment.yaml
│  │  ├─ service.yaml
│  │  ├─ ingress.yaml
│  │  ├─ hpa.yaml
│  │  ├─ api-configmap.yaml
│  │  └─ api-secret.yaml             # 実運用では overlays 側で secretGenerator 推奨
│  ├─ chroma/
│  │  ├─ statefulset.yaml
│  │  └─ service.yaml
│  └─ cronjobs/
│     ├─ ir-src-rebuild.yaml
│     ├─ cj-extract-view.yaml
│     ├─ cj-extract-field.yaml
│     ├─ cj-translate-run.yaml
│     ├─ cj-chroma-package.yaml
│     └─ cj-chroma-upsert.yaml
└─ overlays/
   ├─ dev/
   │  ├─ kustomization.yaml
   │  ├─ patch-portal-api-image.yaml
   │  ├─ patch-ingress-hosts.yaml
   │  ├─ secret-generator.yaml
   │  └─ configmap-generator.yaml
   └─ prod/
      ├─ kustomization.yaml
      ├─ patch-portal-api-image.yaml
      ├─ patch-replicas-resources.yaml
      ├─ patch-ingress-hosts.yaml
      ├─ secret-generator.yaml
      └─ configmap-generator.yaml

下記に古いもの

あなたの構成に合わせて、Kustomize前提の“ワンストップ”ブートストラップを1本のスクリプトにまとめました。
これを実行すると API / SQL ひな形+K8s(baseoverlays/{dev,prod})一式が生成されます。

ポイント

  • base は共通のみ(namespace/Secret/Configは含めない)
  • overlay で namespace を作成し、secretGenerator/configMapGenerator で機密と環境値を差し替え
  • prod は ALB注釈、HPA/IRSA/StorageClass(gp3)のパッチ雛形付き
  • CronJob は curlimages/curl/bin/sh 経由で実行(環境変数展開OK)

#!/usr/bin/env bash
set -euo pipefail

ROOT="${1:-.}"

say() { printf "\033[1;32m==>\033[0m %s\n" "$*"; }

# =====================================================================================
# Dirs
# =====================================================================================
say "Creating directories..."
mkdir -p "$ROOT/api/app/routers" \
         "$ROOT/api/app/schemas" \
         "$ROOT/api/app/services" \
         "$ROOT/api/app/repos" \
         "$ROOT/api/app/utils" \
         "$ROOT/api/app/openapi" \
         "$ROOT/api/sql" \
         "$ROOT/k8s/base/postgres" \
         "$ROOT/k8s/base/odoo" \
         "$ROOT/k8s/base/portal-api" \
         "$ROOT/k8s/base/chroma" \
         "$ROOT/k8s/base/cronjobs" \
         "$ROOT/k8s/overlays/dev" \
         "$ROOT/k8s/overlays/prod" \
         "$ROOT/scripts"

# =====================================================================================
# API: Dockerfile & requirements
# =====================================================================================
say "Scaffolding API base..."
cat > "$ROOT/api/Dockerfile" <<'DOCKER'
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY app /app/app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
DOCKER

cat > "$ROOT/api/requirements.txt" <<'REQ'
fastapi
uvicorn[standard]
pydantic
SQLAlchemy
psycopg2-binary
python-dotenv
requests
REQ

# =====================================================================================
# API: app skeleton
# =====================================================================================
cat > "$ROOT/api/app/__init__.py" <<'PY'
# app package
PY

cat > "$ROOT/api/app/main.py" <<'PY'
from fastapi import FastAPI
from .routers import extract, translate, writeback, package, chroma, status

app = FastAPI(title="Dev Portal API")

app.include_router(extract.router,    prefix="/extract",   tags=["extract"])
app.include_router(translate.router,  prefix="/translate", tags=["translate"])
app.include_router(writeback.router,  prefix="/writeback", tags=["writeback"])
app.include_router(package.router,    prefix="/chroma",    tags=["chroma-package"])
app.include_router(chroma.router,     prefix="/chroma",    tags=["chroma"])
app.include_router(status.router,     prefix="/status",    tags=["status"])

@app.get("/healthz")
def healthz(): return {"ok": True}

@app.get("/livez")
def livez(): return {"ok": True}

@app.get("/startupz")
def startupz(): return {"ok": True}
PY

cat > "$ROOT/api/app/config.py" <<'PY'
from pydantic import BaseSettings

class Settings(BaseSettings):
    # Overridden by K8s ConfigMap/Secret in production
    DATABASE_URL: str = "postgresql+psycopg2://portal:portal@localhost:5432/portal"
    ODOO_DATABASE_URL: str = "postgresql+psycopg2://odoo:odoo@localhost:5432/odoo"
    CHROMA_HOST: str = "localhost"
    CHROMA_PORT: int = 8000

settings = Settings()
PY

cat > "$ROOT/api/app/db.py" <<'PY'
# TODO: SQLAlchemy engine/session
PY

cat > "$ROOT/api/app/routers/__init__.py" <<'PY'
from . import extract, translate, writeback, package, chroma, status
PY

cat > "$ROOT/api/app/routers/extract.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# POST /extract/view_common, POST /extract/field
PY

cat > "$ROOT/api/app/routers/translate.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# POST /translate/run, GET /translate
PY

cat > "$ROOT/api/app/routers/writeback.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# POST /writeback/view_common, POST /writeback/field
PY

cat > "$ROOT/api/app/routers/package.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# POST /chroma/package, GET /chroma/docs
PY

cat > "$ROOT/api/app/routers/chroma.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# POST /chroma/upsert
PY

cat > "$ROOT/api/app/routers/status.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# GET /status/summary, GET /samples/trace
PY

# Schemas
cat > "$ROOT/api/app/schemas/common.py" <<'PY'
from pydantic import BaseModel
from typing import Literal

Lang = Literal["ja", "en"]
Entity = Literal["field", "view_common"]

class NaturalKey(BaseModel):
    entity: Entity
    key: str

class Summary(BaseModel):
    pending: int
    translated: int
    queued: int
    upserted: int
PY

cat > "$ROOT/api/app/schemas/extract_view_common.py" <<'PY'
from pydantic import BaseModel
from typing import List, Literal

class ExtractViewCommonIn(BaseModel):
    action_xmlids: List[str]
    targets: List[Literal["ai_purpose","help"]] = ["ai_purpose","help"]
    mode: Literal["upsert_if_changed","force_overwrite"] = "upsert_if_changed"

class ExtractViewCommonOut(BaseModel):
    queued: int
    skipped: int
PY

cat > "$ROOT/api/app/schemas/extract_field.py" <<'PY'
from pydantic import BaseModel
from typing import List

class ExtractFieldIn(BaseModel):
    models: List[str] = []
    fields: List[str] = []
    mode: str = "upsert_if_changed"

class ExtractFieldOut(BaseModel):
    queued: int
    skipped: int
PY

cat > "$ROOT/api/app/schemas/translate_run.py" <<'PY'
from pydantic import BaseModel
from typing import List
from .common import Entity

class TranslateRunIn(BaseModel):
    limit: int = 200
    source_lang: str = "ja_JP"
    target_lang: str = "en_US"
    entities: List[Entity] = ["field","view_common"]

class TranslateRunOut(BaseModel):
    processed: int
    failed: int
PY

cat > "$ROOT/api/app/schemas/writeback_view_common.py" <<'PY'
from pydantic import BaseModel
class WritebackViewCommonIn(BaseModel):
    mode: str = "skip_if_exists"
    overwrite: bool = False
PY

cat > "$ROOT/api/app/schemas/writeback_field.py" <<'PY'
from pydantic import BaseModel
class WritebackFieldIn(BaseModel):
    mode: str = "skip_if_exists"
    overwrite: bool = False
PY

cat > "$ROOT/api/app/schemas/chroma_package.py" <<'PY'
from pydantic import BaseModel
from typing import Dict, List
from .common import Entity

class ChromaPackageIn(BaseModel):
    entities: List[Entity] = ["field","view_common"]
    lang: str = "ja"
    collections: Dict[str,str] = {"field":"portal_field_ja","view_common":"portal_view_common_ja"}
    limit: int = 500

class ChromaPackageOut(BaseModel):
    queued: int
    skipped: int
PY

cat > "$ROOT/api/app/schemas/chroma_upsert.py" <<'PY'
from pydantic import BaseModel
from typing import List

class ChromaUpsertIn(BaseModel):
    collections: List[str] = ["portal_field_ja","portal_view_common_ja"]
    limit: int = 1000
    dry_run: bool = False

class ChromaUpsertOut(BaseModel):
    upserted: int
    failed: int
PY

cat > "$ROOT/api/app/schemas/status.py" <<'PY'
from pydantic import BaseModel
from .common import Summary

class StatusSummary(BaseModel):
    summary: Summary
PY

# Services/repos/utils placeholders
cat > "$ROOT/api/app/services/extract.py" <<'PY'
# Orchestration for extract
PY
cat > "$ROOT/api/app/services/translate.py" <<'PY'
# Translation worker (pending -> translated)
PY
cat > "$ROOT/api/app/services/writeback.py" <<'PY'
# Optional writeback
PY
cat > "$ROOT/api/app/services/package.py" <<'PY'
# Make docs for Chroma export
PY
cat > "$ROOT/api/app/services/chroma_client.py" <<'PY'
# Chroma upsert/query
PY
cat > "$ROOT/api/app/services/natural_text.py" <<'PY'
# Document text generation templates
PY

for f in portal_translate_repo portal_chroma_doc_repo portal_view_common_repo portal_field_repo portal_model_repo; do
  cat > "$ROOT/api/app/repos/${f}.py" <<PY
# repo placeholder: ${f}
PY
done

cat > "$ROOT/api/app/utils/natural_key.py" <<'PY'
# natural_key build/validate
PY
cat > "$ROOT/api/app/utils/lang_detect.py" <<'PY'
# JA text detection
PY
cat > "$ROOT/api/app/utils/html_strip.py" <<'PY'
# strip HTML
PY
cat > "$ROOT/api/app/utils/text_hash.py" <<'PY'
# text hashing for change detection
PY
cat > "$ROOT/api/app/utils/time.py" <<'PY'
# time utils
PY

cat > "$ROOT/api/app/openapi/openapi.yaml" <<'YAML'
openapi: 3.1.0
info: { title: Dev Portal API, version: 0.0.1 }
paths: {}
YAML

# SQL placeholders
for f in 000_extensions.sql 010_portal_core.sql 020_portal_translate.sql 030_portal_chroma_doc.sql; do
  echo "-- $f (TBD)" > "$ROOT/api/sql/$f"
done

# =====================================================================================
# K8s: BASE (no namespace/secret/config in base)
# =====================================================================================
say "Scaffolding Kubernetes (base)..."

# base/kustomization.yaml
cat > "$ROOT/k8s/base/kustomization.yaml" <<'YAML'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - postgres/statefulset.yaml
  - postgres/service.yaml
  - postgres/init-configmap.yaml
  - odoo/deployment.yaml
  - odoo/service.yaml
  - odoo/ingress.yaml
  - odoo/pvc.yaml
  - chroma/statefulset.yaml
  - chroma/service.yaml
  - portal-api/serviceaccount.yaml
  - portal-api/deployment.yaml
  - portal-api/service.yaml
  - portal-api/ingress.yaml
  - portal-api/hpa.yaml
  - cronjobs/ir-src-rebuild.yaml
  - cronjobs/cj-extract-view.yaml
  - cronjobs/cj-extract-field.yaml
  - cronjobs/cj-translate-run.yaml
  - cronjobs/cj-chroma-package.yaml
  - cronjobs/cj-chroma-upsert.yaml
YAML

# Postgres (expects Secret via overlay)
cat > "$ROOT/k8s/base/postgres/init-configmap.yaml" <<'YAML'
apiVersion: v1
kind: ConfigMap
metadata: { name: postgres-init }
data:
  init-multi-db.sh: |
    #!/usr/bin/env bash
    set -euo pipefail
    psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-SQL
      DO $$
      BEGIN
        IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname=current_setting('odoo.user', true)) THEN
          EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', current_setting('odoo.user', true), current_setting('odoo.pass', true));
        END IF;
        IF NOT EXISTS (SELECT FROM pg_database WHERE datname='odoo') THEN
          EXECUTE format('CREATE DATABASE %I OWNER %I', 'odoo', current_setting('odoo.user', true));
        END IF;
        IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname=current_setting('portal.user', true)) THEN
          EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', current_setting('portal.user', true), current_setting('portal.pass', true));
        END IF;
        IF NOT EXISTS (SELECT FROM pg_database WHERE datname='portal') THEN
          EXECUTE format('CREATE DATABASE %I OWNER %I', 'portal', current_setting('portal.user', true));
        END IF;
      END
      $$;
SQL
    psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -d portal <<-SQL
      CREATE EXTENSION IF NOT EXISTS pgcrypto;
      CREATE EXTENSION IF NOT EXISTS postgres_fdw;
SQL
    psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -d odoo <<-SQL
      CREATE EXTENSION IF NOT EXISTS pgcrypto;
SQL
YAML

cat > "$ROOT/k8s/base/postgres/statefulset.yaml" <<'YAML'
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: postgres }
spec:
  serviceName: postgres
  replicas: 1
  selector: { matchLabels: { app: postgres } }
  template:
    metadata: { labels: { app: postgres } }
    spec:
      securityContext: { fsGroup: 999 }
      containers:
        - name: db
          image: postgres:16
          ports: [{ containerPort: 5432, name: pg }]
          env:
            - { name: POSTGRES_USER, value: postgres }
            - { name: POSTGRES_PASSWORD, valueFrom: { secretKeyRef: { name: postgres-auth, key: POSTGRES_PASSWORD } } }
            - { name: odoo.user, valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_USER } } }
            - { name: odoo.pass, valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_PASSWORD } } }
            - { name: portal.user, valueFrom: { secretKeyRef: { name: postgres-auth, key: PORTAL_DB_USER } } }
            - { name: portal.pass, valueFrom: { secretKeyRef: { name: postgres-auth, key: PORTAL_DB_PASSWORD } } }
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
            - { name: init, mountPath: /docker-entrypoint-initdb.d }
      volumes:
        - name: init
          configMap: { name: postgres-init, defaultMode: 0755 }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: ["ReadWriteOnce"]
        resources: { requests: { storage: 20Gi } }
YAML

cat > "$ROOT/k8s/base/postgres/service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: postgres }
spec:
  selector: { app: postgres }
  ports: [{ name: pg, port: 5432, targetPort: 5432 }]
YAML

# Odoo
cat > "$ROOT/k8s/base/odoo/deployment.yaml" <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata: { name: odoo }
spec:
  replicas: 1
  selector: { matchLabels: { app: odoo } }
  template:
    metadata: { labels: { app: odoo } }
    spec:
      containers:
        - name: odoo
          image: odoo:18
          ports: [{ containerPort: 8069, name: http }]
          env:
            - { name: HOST, value: "0.0.0.0" }
            - { name: ODOO_DB_HOST, value: postgres }
            - { name: ODOO_DB_PORT, value: "5432" }
            - { name: ODOO_DB_USER, valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_USER } } }
            - { name: ODOO_DB_PASSWORD, valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_PASSWORD } } }
            - { name: ODOO_DB_NAME, value: "odoo" }
            - { name: ODOO_PROXY_MODE, value: "true" }
          volumeMounts:
            - { name: filestore, mountPath: /var/lib/odoo }
          readinessProbe: { httpGet: { path: /web, port: http }, initialDelaySeconds: 20, periodSeconds: 5 }
          livenessProbe:  { httpGet: { path: /web, port: http }, initialDelaySeconds: 60, periodSeconds: 10 }
      volumes:
        - name: filestore
          persistentVolumeClaim: { claimName: odoo-filestore }
YAML

cat > "$ROOT/k8s/base/odoo/service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: odoo }
spec:
  selector: { app: odoo }
  ports: [{ name: http, port: 80, targetPort: 8069 }]
YAML

cat > "$ROOT/k8s/base/odoo/ingress.yaml" <<'YAML'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: odoo
  annotations: {}
spec:
  rules:
    - host: odoo.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: { service: { name: odoo, port: { number: 80 } } }
YAML

cat > "$ROOT/k8s/base/odoo/pvc.yaml" <<'YAML'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: odoo-filestore }
spec:
  accessModes: ["ReadWriteOnce"]
  resources: { requests: { storage: 20Gi } }
YAML

# Chroma
cat > "$ROOT/k8s/base/chroma/statefulset.yaml" <<'YAML'
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: chroma }
spec:
  serviceName: chroma
  replicas: 1
  selector: { matchLabels: { app: chroma } }
  template:
    metadata: { labels: { app: chroma } }
    spec:
      containers:
        - name: chroma
          image: ghcr.io/chroma-core/chroma:0.4.24
          ports: [{ containerPort: 8000, name: http }]
          env:
            - { name: CHROMA_PORT, value: "8000" }
            - { name: PERSIST_DIRECTORY, value: /chroma }
          volumeMounts:
            - { name: data, mountPath: /chroma }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: ["ReadWriteOnce"]
        resources: { requests: { storage: 20Gi } }
YAML

cat > "$ROOT/k8s/base/chroma/service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: chroma }
spec:
  selector: { app: chroma }
  ports: [{ name: http, port: 8000, targetPort: 8000 }]
YAML

# Portal API (ServiceAccount for optional IRSA)
cat > "$ROOT/k8s/base/portal-api/serviceaccount.yaml" <<'YAML'
apiVersion: v1
kind: ServiceAccount
metadata: { name: portal-api }
YAML

cat > "$ROOT/k8s/base/portal-api/deployment.yaml" <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata: { name: portal-api }
spec:
  selector: { matchLabels: { app: portal-api } }
  template:
    metadata: { labels: { app: portal-api } }
    spec:
      serviceAccountName: portal-api
      containers:
        - name: api
          image: dev-portal-api:latest
          ports: [{ containerPort: 8000, name: http }]
          env:
            - { name: APP_ENV, valueFrom: { configMapKeyRef: { name: api-config, key: APP_ENV } } }
            - { name: LOG_LEVEL, valueFrom: { configMapKeyRef: { name: api-config, key: LOG_LEVEL } } }
            - { name: API_PORT, valueFrom: { configMapKeyRef: { name: api-config, key: API_PORT } } }
            - { name: DB_HOST, valueFrom: { configMapKeyRef: { name: api-config, key: DB_HOST } } }
            - { name: DB_PORT, valueFrom: { configMapKeyRef: { name: api-config, key: DB_PORT } } }
            - { name: DB_NAME, valueFrom: { configMapKeyRef: { name: api-config, key: DB_NAME } } }
            - { name: CHROMA_HOST, valueFrom: { configMapKeyRef: { name: api-config, key: CHROMA_HOST } } }
            - { name: CHROMA_PORT, valueFrom: { configMapKeyRef: { name: api-config, key: CHROMA_PORT } } }
            - { name: CHROMA_COLLECTION_FIELD_JA, valueFrom: { configMapKeyRef: { name: api-config, key: CHROMA_COLLECTION_FIELD_JA } } }
            - { name: CHROMA_COLLECTION_VIEW_JA,  valueFrom: { configMapKeyRef: { name: api-config, key: CHROMA_COLLECTION_VIEW_JA } } }
            - { name: DB_USER, valueFrom: { secretKeyRef: { name: api-auth, key: DB_USER } } }
            - { name: DB_PASSWORD, valueFrom: { secretKeyRef: { name: api-auth, key: DB_PASSWORD } } }
            - { name: API_BEARER_TOKEN, valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } } }
          readinessProbe: { httpGet: { path: /healthz, port: http }, initialDelaySeconds: 5, periodSeconds: 5 }
          livenessProbe:  { httpGet: { path: /livez,   port: http }, initialDelaySeconds: 10, periodSeconds: 10 }
          startupProbe:   { httpGet: { path: /startupz,port: http }, failureThreshold: 30, periodSeconds: 2 }
YAML

cat > "$ROOT/k8s/base/portal-api/service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: portal-api }
spec:
  selector: { app: portal-api }
  ports: [{ name: http, port: 80, targetPort: 8000 }]
YAML

cat > "$ROOT/k8s/base/portal-api/ingress.yaml" <<'YAML'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: portal-api
  annotations: {}
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: { service: { name: portal-api, port: { number: 80 } } }
YAML

cat > "$ROOT/k8s/base/portal-api/hpa.yaml" <<'YAML'
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: portal-api }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: portal-api }
  minReplicas: 2
  maxReplicas: 8
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }
YAML

# CronJobs (use /bin/sh so $TOKEN expands)
cat > "$ROOT/k8s/base/cronjobs/ir-src-rebuild.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: ir-src-rebuild }
spec:
  schedule: "0 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: psql
              image: postgres:16
              env:
                - { name: DB_HOST, value: postgres }
                - { name: DB_NAME, value: portal }
                - { name: DB_USER, valueFrom: { secretKeyRef: { name: api-auth, key: DB_USER } } }
                - { name: DB_PASSWORD, valueFrom: { secretKeyRef: { name: api-auth, key: DB_PASSWORD } } }
                - { name: ODOO_DB_PASSWORD, valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_PASSWORD } } }
              command: ["sh","-lc"]
              args:
                - |
                  export PGPASSWORD="$DB_PASSWORD";
                  psql "host=$DB_HOST dbname=$DB_NAME user=$DB_USER" -v ON_ERROR_STOP=1 <<'SQL'
                  CREATE EXTENSION IF NOT EXISTS postgres_fdw;
                  -- TODO: ir_*_src rebuild SQL
SQL
YAML

cat > "$ROOT/k8s/base/cronjobs/cj-extract-view.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: cj-extract-view }
spec:
  schedule: "*/30 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: curl
              image: curlimages/curl:8.8.0
              env:
                - name: TOKEN
                  valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } }
              command: ["sh","-lc"]
              args:
                - >
                  curl -sS -X POST
                  -H "Authorization: Bearer $TOKEN"
                  -H "Content-Type: application/json"
                  --data '{"action_xmlids":[],"targets":["ai_purpose","help"],"mode":"upsert_if_changed"}'
                  http://portal-api.$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace).svc.cluster.local/extract/view_common
YAML

cat > "$ROOT/k8s/base/cronjobs/cj-extract-field.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: cj-extract-field }
spec:
  schedule: "15 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: curl
              image: curlimages/curl:8.8.0
              env:
                - name: TOKEN
                  valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } }
              command: ["sh","-lc"]
              args:
                - >
                  curl -sS -X POST
                  -H "Authorization: Bearer $TOKEN"
                  -H "Content-Type: application/json"
                  --data '{"models":[],"fields":[],"mode":"upsert_if_changed"}'
                  http://portal-api.$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace).svc.cluster.local/extract/field
YAML

cat > "$ROOT/k8s/base/cronjobs/cj-translate-run.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: cj-translate-run }
spec:
  schedule: "*/20 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: curl
              image: curlimages/curl:8.8.0
              env:
                - name: TOKEN
                  valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } }
              command: ["sh","-lc"]
              args:
                - >
                  curl -sS -X POST
                  -H "Authorization: Bearer $TOKEN"
                  -H "Content-Type: application/json"
                  --data '{"limit":200,"source_lang":"ja_JP","target_lang":"en_US","entities":["field","view_common"]}'
                  http://portal-api.$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace).svc.cluster.local/translate/run
YAML

cat > "$ROOT/k8s/base/cronjobs/cj-chroma-package.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: cj-chroma-package }
spec:
  schedule: "*/30 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: curl
              image: curlimages/curl:8.8.0
              env:
                - name: TOKEN
                  valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } }
              command: ["sh","-lc"]
              args:
                - >
                  curl -sS -X POST
                  -H "Authorization: Bearer $TOKEN"
                  -H "Content-Type: application/json"
                  --data '{"entities":["field","view_common"],"lang":"ja","collections":{"field":"portal_field_ja","view_common":"portal_view_common_ja"},"limit":500}'
                  http://portal-api.$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace).svc.cluster.local/chroma/package
YAML

cat > "$ROOT/k8s/base/cronjobs/cj-chroma-upsert.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: cj-chroma-upsert }
spec:
  schedule: "*/15 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: curl
              image: curlimages/curl:8.8.0
              env:
                - name: TOKEN
                  valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } }
              command: ["sh","-lc"]
              args:
                - >
                  curl -sS -X POST
                  -H "Authorization: Bearer $TOKEN"
                  -H "Content-Type: application/json"
                  --data '{"collections":["portal_field_ja","portal_view_common_ja"],"limit":1000,"dry_run":false}'
                  http://portal-api.$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace).svc.cluster.local/chroma/upsert
YAML

# =====================================================================================
# K8s: OVERLAYS (dev)
# =====================================================================================
say "Scaffolding overlays/dev..."

cat > "$ROOT/k8s/overlays/dev/namespace.yaml" <<'YAML'
apiVersion: v1
kind: Namespace
metadata: { name: portal-dev }
YAML

cat > "$ROOT/k8s/overlays/dev/kustomization.yaml" <<'YAML'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: portal-dev

resources:
  - ../../base
  - namespace.yaml

generatorOptions:
  disableNameSuffixHash: true

# Replace config/secret from base expectations
configMapGenerator:
  - name: api-config
    behavior: replace
    literals:
      - APP_ENV=dev
      - LOG_LEVEL=info
      - API_PORT=8000
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_NAME=portal
      - CHROMA_HOST=chroma
      - CHROMA_PORT=8000
      - CHROMA_COLLECTION_FIELD_JA=portal_field_ja
      - CHROMA_COLLECTION_VIEW_JA=portal_view_common_ja

secretGenerator:
  - name: api-auth
    behavior: replace
    literals:
      - DB_USER=portal
      - DB_PASSWORD=change-portal-pass
      - API_BEARER_TOKEN=dev-token
  - name: postgres-auth
    behavior: replace
    literals:
      - POSTGRES_PASSWORD=change-super-pass
      - ODOO_DB_USER=odoo
      - ODOO_DB_PASSWORD=change-odoo-pass
      - PORTAL_DB_USER=portal
      - PORTAL_DB_PASSWORD=change-portal-pass

patchesStrategicMerge:
  - patch-ingress-hosts.yaml
  - patch-api-image.yaml
YAML

cat > "$ROOT/k8s/overlays/dev/patch-ingress-hosts.yaml" <<'YAML'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: odoo }
spec:
  rules:
    - host: odoo.dev.example.com
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: portal-api }
spec:
  rules:
    - host: api.dev.example.com
YAML

cat > "$ROOT/k8s/overlays/dev/patch-api-image.yaml" <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata: { name: portal-api }
spec:
  template:
    spec:
      containers:
        - name: api
          image: your-registry/portal-api:dev
YAML

# =====================================================================================
# K8s: OVERLAYS (prod)
# =====================================================================================
say "Scaffolding overlays/prod..."

cat > "$ROOT/k8s/overlays/prod/namespace.yaml" <<'YAML'
apiVersion: v1
kind: Namespace
metadata: { name: portal-prod }
YAML

cat > "$ROOT/k8s/overlays/prod/kustomization.yaml" <<'YAML'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: portal-prod

resources:
  - ../../base
  - namespace.yaml

generatorOptions:
  disableNameSuffixHash: true

configMapGenerator:
  - name: api-config
    behavior: replace
    literals:
      - APP_ENV=prod
      - LOG_LEVEL=info
      - API_PORT=8000
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_NAME=portal
      - CHROMA_HOST=chroma
      - CHROMA_PORT=8000
      - CHROMA_COLLECTION_FIELD_JA=portal_field_ja
      - CHROMA_COLLECTION_VIEW_JA=portal_view_common_ja

secretGenerator:
  - name: api-auth
    behavior: replace
    literals:
      - DB_USER=portal
      - DB_PASSWORD=***REDACTED***
      - API_BEARER_TOKEN=***REDACTED***
  - name: postgres-auth
    behavior: replace
    literals:
      - POSTGRES_PASSWORD=***REDACTED***
      - ODOO_DB_USER=odoo
      - ODOO_DB_PASSWORD=***REDACTED***
      - PORTAL_DB_USER=portal
      - PORTAL_DB_PASSWORD=***REDACTED***

patchesStrategicMerge:
  - patch-ingress-aws.yaml
  - patch-hpa.yaml
  - patch-storageclass.yaml
  - patch-irsa.yaml
  - patch-api-image.yaml
YAML

cat > "$ROOT/k8s/overlays/prod/patch-ingress-aws.yaml" <<'YAML'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: portal-api
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:REGION:ACCOUNT:certificate/XXXX
spec:
  rules:
    - host: api.example.com
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: odoo
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:REGION:ACCOUNT:certificate/YYYY
spec:
  rules:
    - host: odoo.example.com
YAML

cat > "$ROOT/k8s/overlays/prod/patch-hpa.yaml" <<'YAML'
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: portal-api }
spec:
  minReplicas: 4
  maxReplicas: 10
YAML

cat > "$ROOT/k8s/overlays/prod/patch-storageclass.yaml" <<'YAML'
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: postgres }
spec:
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        storageClassName: gp3
        resources: { requests: { storage: 100Gi } }
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: chroma }
spec:
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        storageClassName: gp3
        resources: { requests: { storage: 50Gi } }
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: odoo-filestore }
spec:
  storageClassName: gp3
  resources: { requests: { storage: 100Gi } }
YAML

cat > "$ROOT/k8s/overlays/prod/patch-irsa.yaml" <<'YAML'
apiVersion: v1
kind: ServiceAccount
metadata:
  name: portal-api
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/portal-api-irsa
YAML

cat > "$ROOT/k8s/overlays/prod/patch-api-image.yaml" <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata: { name: portal-api }
spec:
  template:
    spec:
      containers:
        - name: api
          image: your-registry/portal-api:1.0.0
YAML

# =====================================================================================
# Helper scripts
# =====================================================================================
cat > "$ROOT/scripts/apply_dev.sh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
kubectl apply -k k8s/overlays/dev
SH
chmod +x "$ROOT/scripts/apply_dev.sh"

cat > "$ROOT/scripts/apply_prod.sh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
kubectl apply -k k8s/overlays/prod
SH
chmod +x "$ROOT/scripts/apply_prod.sh"

say "✅ Bootstrap complete."
echo
echo "Next steps:"
echo "  1) Build API image:   docker build --no-cache -t your-registry/portal-api:dev -f api/Dockerfile api"
echo "  2) Push image (if needed) and then:"
echo "     - Dev : ./scripts/apply_dev.sh"
echo "     - Prod: ./scripts/apply_prod.sh   (requires ALB Controller/EBS CSI/ACM ready)"

下記の**ワンコマンド(ブートストラップスクリプト)**を保存して実行すると、あなたが提示した API 構成(routers/schemas/services/repos/utils…)に加え、Kubernetes の Odoo / Postgres / API / Chroma / CronJob まで一式のディレクトリとプレースホルダーファイルを生成します。

使い方:

  1. 保存 → chmod +x bootstrap_full.sh
  2. ./bootstrap_full.sh(カレント直下に生成)
    もしくは ./bootstrap_full.sh /path/to/project

#!/usr/bin/env bash
set -euo pipefail

ROOT="${1:-.}"

say() { printf "\033[1;32m==>\033[0m %s\n" "$*"; }

# ------------------------------------------------------------------------------
# Dirs
# ------------------------------------------------------------------------------
say "Creating directories..."
mkdir -p "$ROOT/api/app/routers" \
         "$ROOT/api/app/schemas" \
         "$ROOT/api/app/services" \
         "$ROOT/api/app/repos" \
         "$ROOT/api/app/utils" \
         "$ROOT/api/app/openapi" \
         "$ROOT/api/sql" \
         "$ROOT/k8s/dev" \
         "$ROOT/k8s/infra/postgres" \
         "$ROOT/k8s/odoo" \
         "$ROOT/k8s/cronjobs" \
         "$ROOT/k8s/chroma" \
         "$ROOT/scripts"

# ------------------------------------------------------------------------------
# Top-level: Dockerfile + requirements
# ------------------------------------------------------------------------------
say "Scaffolding API base..."
cat > "$ROOT/api/Dockerfile" <<'DOCKER'
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY app /app/app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
DOCKER

cat > "$ROOT/api/requirements.txt" <<'REQ'
fastapi
uvicorn[standard]
pydantic
SQLAlchemy
psycopg2-binary
python-dotenv
requests
REQ

# ------------------------------------------------------------------------------
# app core
# ------------------------------------------------------------------------------
cat > "$ROOT/api/app/__init__.py" <<'PY'
# app package
PY

cat > "$ROOT/api/app/main.py" <<'PY'
from fastapi import FastAPI

from .routers import (
    extract, translate, writeback, package, chroma, status
)

app = FastAPI(title="Dev Portal API")

# pipeline routers
app.include_router(extract.router,    prefix="/extract",   tags=["extract"])
app.include_router(translate.router,  prefix="/translate", tags=["translate"])
app.include_router(writeback.router,  prefix="/writeback", tags=["writeback"])
app.include_router(package.router,    prefix="/chroma",    tags=["chroma-package"])
app.include_router(chroma.router,     prefix="/chroma",    tags=["chroma"])
app.include_router(status.router,     prefix="/status",    tags=["status"])

@app.get("/healthz")
def healthz(): return {"ok": True}

@app.get("/livez")
def livez(): return {"ok": True}

@app.get("/startupz")
def startupz(): return {"ok": True}
PY

cat > "$ROOT/api/app/config.py" <<'PY'
from pydantic import BaseSettings

class Settings(BaseSettings):
    # K8s の ConfigMap/Secret で上書きする前提のデフォルト
    DATABASE_URL: str = "postgresql+psycopg2://portal:portal@localhost:5432/portal"
    ODOO_DATABASE_URL: str = "postgresql+psycopg2://odoo:odoo@localhost:5432/odoo"
    CHROMA_HOST: str = "localhost"
    CHROMA_PORT: int = 8000

settings = Settings()
PY

cat > "$ROOT/api/app/db.py" <<'PY'
# TODO: SQLAlchemy engine/session をここに実装
PY

# ------------------------------------------------------------------------------
# routers
# ------------------------------------------------------------------------------
cat > "$ROOT/api/app/routers/__init__.py" <<'PY'
from . import extract, translate, writeback, package, chroma, status
PY

# ① 抽出
cat > "$ROOT/api/app/routers/extract.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# POST /extract/view_common, POST /extract/field をここに実装していきます
PY

# ② 翻訳
cat > "$ROOT/api/app/routers/translate.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# POST /translate/run, GET /translate をここに
PY

# ③ 書き戻し(任意)
cat > "$ROOT/api/app/routers/writeback.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# POST /writeback/view_common, POST /writeback/field をここに
PY

# ④ パッケージ
cat > "$ROOT/api/app/routers/package.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# POST /chroma/package, GET /chroma/docs をここに
PY

# ⑤ Chroma upsert
cat > "$ROOT/api/app/routers/chroma.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# POST /chroma/upsert をここに
PY

# ダッシュボード
cat > "$ROOT/api/app/routers/status.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# GET /status/summary, GET /samples/trace をここに
PY

# ------------------------------------------------------------------------------
# schemas
# ------------------------------------------------------------------------------
cat > "$ROOT/api/app/schemas/common.py" <<'PY'
from pydantic import BaseModel
from typing import Literal

Lang = Literal["ja", "en"]
Entity = Literal["field", "view_common"]

class NaturalKey(BaseModel):
    entity: Entity
    key: str  # 例: field:{model}.{field_name} / view_common:{action_xmlid}

class Summary(BaseModel):
    pending: int
    translated: int
    queued: int
    upserted: int
PY

cat > "$ROOT/api/app/schemas/extract_view_common.py" <<'PY'
from pydantic import BaseModel
from typing import List, Literal

class ExtractViewCommonIn(BaseModel):
    action_xmlids: List[str]
    targets: List[Literal["ai_purpose","help"]] = ["ai_purpose","help"]
    mode: Literal["upsert_if_changed","force_overwrite"] = "upsert_if_changed"

class ExtractViewCommonOut(BaseModel):
    queued: int
    skipped: int
PY

cat > "$ROOT/api/app/schemas/extract_field.py" <<'PY'
from pydantic import BaseModel
from typing import List, Optional

class ExtractFieldIn(BaseModel):
    models: List[str] = []
    fields: List[str] = []
    mode: str = "upsert_if_changed"

class ExtractFieldOut(BaseModel):
    queued: int
    skipped: int
PY

cat > "$ROOT/api/app/schemas/translate_run.py" <<'PY'
from pydantic import BaseModel
from typing import List
from .common import Entity

class TranslateRunIn(BaseModel):
    limit: int = 200
    source_lang: str = "ja_JP"
    target_lang: str = "en_US"
    entities: List[Entity] = ["field","view_common"]

class TranslateRunOut(BaseModel):
    processed: int
    failed: int
PY

cat > "$ROOT/api/app/schemas/writeback_view_common.py" <<'PY'
from pydantic import BaseModel

class WritebackViewCommonIn(BaseModel):
    mode: str = "skip_if_exists"
    overwrite: bool = False
PY

cat > "$ROOT/api/app/schemas/writeback_field.py" <<'PY'
from pydantic import BaseModel

class WritebackFieldIn(BaseModel):
    mode: str = "skip_if_exists"
    overwrite: bool = False
PY

cat > "$ROOT/api/app/schemas/chroma_package.py" <<'PY'
from pydantic import BaseModel
from typing import Dict, List
from .common import Entity

class ChromaPackageIn(BaseModel):
    entities: List[Entity] = ["field","view_common"]
    lang: str = "ja"
    collections: Dict[str,str] = {"field":"portal_field_ja","view_common":"portal_view_common_ja"}
    limit: int = 500

class ChromaPackageOut(BaseModel):
    queued: int
    skipped: int
PY

cat > "$ROOT/api/app/schemas/chroma_upsert.py" <<'PY'
from pydantic import BaseModel
from typing import List

class ChromaUpsertIn(BaseModel):
    collections: List[str] = ["portal_field_ja","portal_view_common_ja"]
    limit: int = 1000
    dry_run: bool = False

class ChromaUpsertOut(BaseModel):
    upserted: int
    failed: int
PY

cat > "$ROOT/api/app/schemas/status.py" <<'PY'
from pydantic import BaseModel
from .common import Summary

class StatusSummary(BaseModel):
    summary: Summary
PY

# ------------------------------------------------------------------------------
# services
# ------------------------------------------------------------------------------
cat > "$ROOT/api/app/services/extract.py" <<'PY'
# 抽出のオーケストレーションを実装していきます
PY
cat > "$ROOT/api/app/services/translate.py" <<'PY'
# 翻訳ワーカー(pending→translated)
PY
cat > "$ROOT/api/app/services/writeback.py" <<'PY'
# 書き戻し(任意)
PY
cat > "$ROOT/api/app/services/package.py" <<'PY'
# translated→ready_for_chroma + chroma_doc作成
PY
cat > "$ROOT/api/app/services/chroma_client.py" <<'PY'
# Chroma接続(upsert/query)
PY
cat > "$ROOT/api/app/services/natural_text.py" <<'PY'
# doc_text生成(日本語テンプレ整形)
PY

# ------------------------------------------------------------------------------
# repos
# ------------------------------------------------------------------------------
for f in portal_translate_repo portal_chroma_doc_repo portal_view_common_repo portal_field_repo portal_model_repo; do
  cat > "$ROOT/api/app/repos/${f}.py" <<PY
# repo placeholder: ${f}
PY
done

# ------------------------------------------------------------------------------
# utils
# ------------------------------------------------------------------------------
cat > "$ROOT/api/app/utils/natural_key.py" <<'PY'
# natural_key生成/検証(正規化・lower/trim)
PY
cat > "$ROOT/api/app/utils/lang_detect.py" <<'PY'
# 日本語判定(ぁ-ん/ァ-ン/一-龥)
PY
cat > "$ROOT/api/app/utils/html_strip.py" <<'PY'
# HTML除去(help_*_html→text)
PY
cat > "$ROOT/api/app/utils/text_hash.py" <<'PY'
# source_hash計算(差分判定)
PY
cat > "$ROOT/api/app/utils/time.py" <<'PY'
# ISO時刻などユーティリティ
PY

# ------------------------------------------------------------------------------
# openapi
# ------------------------------------------------------------------------------
cat > "$ROOT/api/app/openapi/openapi.yaml" <<'YAML'
openapi: 3.1.0
info: { title: Dev Portal API, version: 0.0.1 }
paths: {}
YAML

# ------------------------------------------------------------------------------
# SQL placeholders
# ------------------------------------------------------------------------------
for f in 000_extensions.sql 010_portal_core.sql 020_portal_translate.sql 030_portal_chroma_doc.sql; do
  echo "-- $f (TBD)" > "$ROOT/api/sql/$f"
done

# ------------------------------------------------------------------------------
# Kubernetes: Namespaces
# ------------------------------------------------------------------------------
say "Scaffolding Kubernetes manifests..."
cat > "$ROOT/k8s/namespaces.yaml" <<'YAML'
apiVersion: v1
kind: Namespace
metadata: { name: portal-dev }
YAML

# ------------------------------------------------------------------------------
# Kubernetes: Postgres (shared instance: DB odoo & portal)
# ------------------------------------------------------------------------------
cat > "$ROOT/k8s/infra/postgres/auth-secret.yaml" <<'YAML'
apiVersion: v1
kind: Secret
metadata: { name: postgres-auth, namespace: portal-dev }
type: Opaque
stringData:
  POSTGRES_PASSWORD: change-super-pass
  ODOO_DB_USER: odoo
  ODOO_DB_PASSWORD: change-odoo-pass
  PORTAL_DB_USER: portal
  PORTAL_DB_PASSWORD: change-portal-pass
YAML

cat > "$ROOT/k8s/infra/postgres/init-configmap.yaml" <<'YAML'
apiVersion: v1
kind: ConfigMap
metadata: { name: postgres-init, namespace: portal-dev }
data:
  init-multi-db.sh: |
    #!/usr/bin/env bash
    set -euo pipefail
    psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-SQL
      DO $$
      BEGIN
        IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname=current_setting('odoo.user', true)) THEN
          EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', current_setting('odoo.user', true), current_setting('odoo.pass', true));
        END IF;
        IF NOT EXISTS (SELECT FROM pg_database WHERE datname='odoo') THEN
          EXECUTE format('CREATE DATABASE %I OWNER %I', 'odoo', current_setting('odoo.user', true));
        END IF;
        IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname=current_setting('portal.user', true)) THEN
          EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', current_setting('portal.user', true), current_setting('portal.pass', true));
        END IF;
        IF NOT EXISTS (SELECT FROM pg_database WHERE datname='portal') THEN
          EXECUTE format('CREATE DATABASE %I OWNER %I', 'portal', current_setting('portal.user', true));
        END IF;
      END
      $$;
SQL
    psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -d portal <<-SQL
      CREATE EXTENSION IF NOT EXISTS pgcrypto;
      CREATE EXTENSION IF NOT EXISTS postgres_fdw;
SQL
    psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" -d odoo <<-SQL
      CREATE EXTENSION IF NOT EXISTS pgcrypto;
SQL
YAML

cat > "$ROOT/k8s/infra/postgres/statefulset.yaml" <<'YAML'
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: postgres, namespace: portal-dev }
spec:
  serviceName: postgres
  replicas: 1
  selector: { matchLabels: { app: postgres } }
  template:
    metadata: { labels: { app: postgres } }
    spec:
      securityContext: { fsGroup: 999 }
      containers:
        - name: db
          image: postgres:16
          ports: [{ containerPort: 5432, name: pg }]
          env:
            - { name: POSTGRES_USER, value: postgres }
            - { name: POSTGRES_PASSWORD, valueFrom: { secretKeyRef: { name: postgres-auth, key: POSTGRES_PASSWORD } } }
            - { name: odoo.user, valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_USER } } }
            - { name: odoo.pass, valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_PASSWORD } } }
            - { name: portal.user, valueFrom: { secretKeyRef: { name: postgres-auth, key: PORTAL_DB_USER } } }
            - { name: portal.pass, valueFrom: { secretKeyRef: { name: postgres-auth, key: PORTAL_DB_PASSWORD } } }
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
            - { name: init, mountPath: /docker-entrypoint-initdb.d }
      volumes:
        - name: init
          configMap: { name: postgres-init, defaultMode: 0755 }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: ["ReadWriteOnce"]
        resources: { requests: { storage: 20Gi } }
YAML

cat > "$ROOT/k8s/infra/postgres/service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: postgres, namespace: portal-dev }
spec:
  selector: { app: postgres }
  ports: [{ name: pg, port: 5432, targetPort: 5432 }]
YAML

# ------------------------------------------------------------------------------
# Kubernetes: Odoo
# ------------------------------------------------------------------------------
cat > "$ROOT/k8s/odoo/odoo-configmap.yaml" <<'YAML'
apiVersion: v1
kind: ConfigMap
metadata: { name: odoo-config, namespace: portal-dev }
data:
  ODOO_DB_NAME: odoo
  ODOO_DB_HOST: postgres
  ODOO_DB_PORT: "5432"
  ODOO_PROXY_MODE: "true"
  ODOO_DB_FILTER: "^odoo$"
  ODOO_LOG_LEVEL: info
YAML

cat > "$ROOT/k8s/odoo/odoo-deployment.yaml" <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata: { name: odoo, namespace: portal-dev }
spec:
  replicas: 1
  selector: { matchLabels: { app: odoo } }
  template:
    metadata: { labels: { app: odoo } }
    spec:
      containers:
        - name: odoo
          image: odoo:16.0
          ports: [{ containerPort: 8069, name: http }]
          envFrom: [{ configMapRef: { name: odoo-config } }]
          env:
            - { name: ODOO_DB_USER, valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_USER } } }
            - { name: ODOO_DB_PASSWORD, valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_PASSWORD } } }
          volumeMounts:
            - { name: filestore, mountPath: /var/lib/odoo }
          readinessProbe: { httpGet: { path: /web, port: http }, initialDelaySeconds: 20, periodSeconds: 5 }
          livenessProbe:  { httpGet: { path: /web, port: http }, initialDelaySeconds: 60, periodSeconds: 10 }
      volumes:
        - name: filestore
          persistentVolumeClaim: { claimName: odoo-filestore }
YAML

cat > "$ROOT/k8s/odoo/odoo-service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: odoo, namespace: portal-dev }
spec:
  selector: { app: odoo }
  ports: [{ name: http, port: 80, targetPort: 8069 }]
YAML

cat > "$ROOT/k8s/odoo/odoo-ingress.yaml" <<'YAML'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: odoo
  namespace: portal-dev
  annotations: { kubernetes.io/ingress.class: nginx }
spec:
  rules:
    - host: odoo.dev.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: { service: { name: odoo, port: { number: 80 } } }
YAML

cat > "$ROOT/k8s/odoo/odoo-pvc.yaml" <<'YAML'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: odoo-filestore, namespace: portal-dev }
spec:
  accessModes: ["ReadWriteOnce"]
  resources: { requests: { storage: 20Gi } }
YAML

# ------------------------------------------------------------------------------
# Kubernetes: API (Portal)
# ------------------------------------------------------------------------------
cat > "$ROOT/k8s/dev/api-configmap.yaml" <<'YAML'
apiVersion: v1
kind: ConfigMap
metadata: { name: api-config, namespace: portal-dev }
data:
  APP_ENV: dev
  LOG_LEVEL: info
  API_PORT: "8000"
  DB_HOST: postgres
  DB_PORT: "5432"
  DB_NAME: portal
  CHROMA_HOST: chroma
  CHROMA_PORT: "8000"
  CHROMA_COLLECTION_FIELD_JA: portal_field_ja
  CHROMA_COLLECTION_VIEW_JA: portal_view_common_ja
YAML

cat > "$ROOT/k8s/dev/api-secret.yaml" <<'YAML'
apiVersion: v1
kind: Secret
metadata: { name: api-auth, namespace: portal-dev }
type: Opaque
stringData:
  DB_USER: portal
  DB_PASSWORD: change-portal-pass
  API_BEARER_TOKEN: dev-token
YAML

cat > "$ROOT/k8s/dev/api-deployment.yaml" <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata: { name: portal-api, namespace: portal-dev }
spec:
  replicas: 2
  selector: { matchLabels: { app: portal-api } }
  template:
    metadata: { labels: { app: portal-api } }
    spec:
      containers:
        - name: api
          image: dev-portal-api:latest
          ports: [{ containerPort: 8000, name: http }]
          envFrom:
            - { configMapRef: { name: api-config } }
            - { secretRef:    { name: api-auth } }
          readinessProbe: { httpGet: { path: /healthz, port: http }, initialDelaySeconds: 5, periodSeconds: 5 }
          livenessProbe:  { httpGet: { path: /livez,   port: http }, initialDelaySeconds: 10, periodSeconds: 10 }
          startupProbe:   { httpGet: { path: /startupz,port: http }, failureThreshold: 30, periodSeconds: 2 }
YAML

cat > "$ROOT/k8s/dev/api-service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: portal-api, namespace: portal-dev }
spec:
  selector: { app: portal-api }
  ports: [{ name: http, port: 80, targetPort: 8000 }]
YAML

cat > "$ROOT/k8s/dev/ingress-api.yaml" <<'YAML'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: portal-api
  namespace: portal-dev
  annotations:
    kubernetes.io/ingress.class: nginx
spec:
  rules:
    - host: api.dev.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: { service: { name: portal-api, port: { number: 80 } } }
YAML

cat > "$ROOT/k8s/dev/hpa-api.yaml" <<'YAML'
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: portal-api, namespace: portal-dev }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: portal-api }
  minReplicas: 2
  maxReplicas: 8
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }
YAML

# ------------------------------------------------------------------------------
# Kubernetes: Chroma (dev)
# ------------------------------------------------------------------------------
cat > "$ROOT/k8s/chroma/statefulset.yaml" <<'YAML'
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: chroma, namespace: portal-dev }
spec:
  serviceName: chroma
  replicas: 1
  selector: { matchLabels: { app: chroma } }
  template:
    metadata: { labels: { app: chroma } }
    spec:
      containers:
        - name: chroma
          image: chromadb/chroma:latest
          ports: [{ containerPort: 8000, name: http }]
          env:
            - { name: CHROMA_SERVER_HOST, value: "0.0.0.0" }
            - { name: CHROMA_SERVER_HTTP_PORT, value: "8000" }
          volumeMounts:
            - { name: data, mountPath: /chroma/chroma }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: ["ReadWriteOnce"]
        resources: { requests: { storage: 20Gi } }
YAML

cat > "$ROOT/k8s/chroma/service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: chroma, namespace: portal-dev }
spec:
  selector: { app: chroma }
  ports: [{ name: http, port: 8000, targetPort: 8000 }]
YAML

# ------------------------------------------------------------------------------
# Kubernetes: DB migration Job (apply DDL)
# ------------------------------------------------------------------------------
cat > "$ROOT/k8s/dev/portal-db-migrate-job.yaml" <<'YAML'
apiVersion: batch/v1
kind: Job
metadata: { name: portal-db-migrate, namespace: portal-dev }
spec:
  template:
    spec:
      restartPolicy: OnFailure
      containers:
        - name: migrate
          image: dev-portal-api:latest
          envFrom:
            - { configMapRef: { name: api-config } }
            - { secretRef:    { name: api-auth } }
          command: ["sh", "-c"]
          args:
            - >
              python -m scripts.apply_sql /app/sql/000_extensions.sql &&
              python -m scripts.apply_sql /app/sql/010_portal_core.sql &&
              python -m scripts.apply_sql /app/sql/020_portal_translate.sql &&
              python -m scripts.apply_sql /app/sql/030_portal_chroma_doc.sql
YAML

# ------------------------------------------------------------------------------
# Kubernetes: CronJobs (ir_*_src rebuild & pipeline)
# ------------------------------------------------------------------------------
cat > "$ROOT/k8s/cronjobs/ir-src-rebuild.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: ir-src-rebuild, namespace: portal-dev }
spec:
  schedule: "0 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: psql
              image: postgres:16
              env:
                - { name: DB_HOST, value: postgres }
                - { name: DB_NAME, value: portal }
                - { name: DB_USER, valueFrom: { secretKeyRef: { name: api-auth, key: DB_USER } } }
                - { name: DB_PASSWORD, valueFrom: { secretKeyRef: { name: api-auth, key: DB_PASSWORD } } }
                - { name: ODOO_DB_PASSWORD, valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_PASSWORD } } }
              command: ["bash", "-lc"]
              args:
                - |
                  export PGPASSWORD="$DB_PASSWORD";
                  psql "host=$DB_HOST dbname=$DB_NAME user=$DB_USER" -v ON_ERROR_STOP=1 <<'SQL'
                  CREATE EXTENSION IF NOT EXISTS postgres_fdw;
                  DO $$
                  BEGIN
                    IF NOT EXISTS (SELECT 1 FROM pg_foreign_server WHERE srvname='odoo_server') THEN
                      CREATE SERVER odoo_server FOREIGN DATA WRAPPER postgres_fdw
                        OPTIONS (host 'postgres', dbname 'odoo', port '5432');
                    END IF;
                  END$$;
                  CREATE SCHEMA IF NOT EXISTS odoo_fdw;
                  CREATE USER MAPPING IF NOT EXISTS FOR CURRENT_USER SERVER odoo_server
                    OPTIONS (user 'odoo', password :'odoo_pass');
                  -- ここに action-centric の ir_*_src 再構築SQLを貼り付けてください
SQL
              envFrom:
                - secretRef:
                    name: postgres-auth
              env:
                - name: odoo_pass
                  valueFrom: { secretKeyRef: { name: postgres-auth, key: ODOO_DB_PASSWORD } }
YAML

cat > "$ROOT/k8s/cronjobs/cj-extract-view.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: cj-extract-view, namespace: portal-dev }
spec:
  schedule: "*/30 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: curl
              image: curlimages/curl:8.8.0
              env:
                - name: TOKEN
                  valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } }
              args:
                - >
                  -sS -X POST
                  -H "Authorization: Bearer $(TOKEN)"
                  -H "Content-Type: application/json"
                  --data '{"action_xmlids":[],"targets":["ai_purpose","help"],"mode":"upsert_if_changed"}'
                  http://portal-api.portal-dev.svc.cluster.local/extract/view_common
YAML

cat > "$ROOT/k8s/cronjobs/cj-extract-field.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: cj-extract-field, namespace: portal-dev }
spec:
  schedule: "15 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: curl
              image: curlimages/curl:8.8.0
              env:
                - name: TOKEN
                  valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } }
              args:
                - >
                  -sS -X POST
                  -H "Authorization: Bearer $(TOKEN)"
                  -H "Content-Type: application/json"
                  --data '{"models":[],"fields":[],"mode":"upsert_if_changed"}'
                  http://portal-api.portal-dev.svc.cluster.local/extract/field
YAML

cat > "$ROOT/k8s/cronjobs/cj-translate-run.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: cj-translate-run, namespace: portal-dev }
spec:
  schedule: "*/20 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: curl
              image: curlimages/curl:8.8.0
              env:
                - name: TOKEN
                  valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } }
              args:
                - >
                  -sS -X POST
                  -H "Authorization: Bearer $(TOKEN)"
                  -H "Content-Type: application/json"
                  --data '{"limit":200,"source_lang":"ja_JP","target_lang":"en_US","entities":["field","view_common"]}'
                  http://portal-api.portal-dev.svc.cluster.local/translate/run
YAML

cat > "$ROOT/k8s/cronjobs/cj-chroma-package.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: cj-chroma-package, namespace: portal-dev }
spec:
  schedule: "*/30 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: curl
              image: curlimages/curl:8.8.0
              env:
                - name: TOKEN
                  valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } }
              args:
                - >
                  -sS -X POST
                  -H "Authorization: Bearer $(TOKEN)"
                  -H "Content-Type: application/json"
                  --data '{"entities":["field","view_common"],"lang":"ja","collections":{"field":"portal_field_ja","view_common":"portal_view_common_ja"},"limit":500}'
                  http://portal-api.portal-dev.svc.cluster.local/chroma/package
YAML

cat > "$ROOT/k8s/cronjobs/cj-chroma-upsert.yaml" <<'YAML'
apiVersion: batch/v1
kind: CronJob
metadata: { name: cj-chroma-upsert, namespace: portal-dev }
spec:
  schedule: "*/15 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: curl
              image: curlimages/curl:8.8.0
              env:
                - name: TOKEN
                  valueFrom: { secretKeyRef: { name: api-auth, key: API_BEARER_TOKEN } }
              args:
                - >
                  -sS -X POST
                  -H "Authorization: Bearer $(TOKEN)"
                  -H "Content-Type: application/json"
                  --data '{"collections":["portal_field_ja","portal_view_common_ja"],"limit":1000,"dry_run":false}'
                  http://portal-api.portal-dev.svc.cluster.local/chroma/upsert
YAML

# ------------------------------------------------------------------------------
# Helper scripts
# ------------------------------------------------------------------------------
cat > "$ROOT/scripts/m1_apply.sh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
kubectl apply -f k8s/namespaces.yaml
kubectl -n portal-dev apply -f k8s/infra/postgres/
kubectl -n portal-dev apply -f k8s/odoo/
kubectl -n portal-dev apply -f k8s/chroma/
kubectl -n portal-dev apply -f k8s/dev/
kubectl -n portal-dev apply -f k8s/cronjobs/
SH
chmod +x "$ROOT/scripts/m1_apply.sh"

cat > "$ROOT/scripts/m1_delete.sh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
kubectl -n portal-dev delete -f k8s/cronjobs/ --ignore-not-found
kubectl -n portal-dev delete -f k8s/dev/ --ignore-not-found
kubectl -n portal-dev delete -f k8s/chroma/ --ignore-not-found
kubectl -n portal-dev delete -f k8s/odoo/ --ignore-not-found
kubectl -n portal-dev delete -f k8s/infra/postgres/ --ignore-not-found
kubectl delete -f k8s/namespaces.yaml --ignore-not-found
SH
chmod +x "$ROOT/scripts/m1_delete.sh"

say "✅ Bootstrap complete."

cat > bootstrap_portal_tree.sh <<'BASH'
#!/usr/bin/env bash
set -euo pipefail

ROOT="${1:-.}"

say() { printf "\033[1;32m==>\033[0m %s\n" "$*"; }

# --- Dirs ------------------------------------------------------------------
say "Creating directories..."
mkdir -p "$ROOT/api/app/routers" \
         "$ROOT/api/app/schemas" \
         "$ROOT/api/app/services" \
         "$ROOT/k8s/dev" \
         "$ROOT/k8s/infra/postgres" \
         "$ROOT/k8s/odoo" \
         "$ROOT/scripts"

# --- Top-level files -------------------------------------------------------
say "Creating top-level files..."
cat > "$ROOT/api/Dockerfile" <<'DOCKER'
# minimal FastAPI image
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY app /app/app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
DOCKER

cat > "$ROOT/api/requirements.txt" <<'REQ'
fastapi
uvicorn[standard]
pydantic
SQLAlchemy
psycopg2-binary
python-dotenv
REQ

# optional placeholder
: > "$ROOT/api/chromadb.yaml"

# --- app: core files -------------------------------------------------------
say "Scaffolding app core..."
cat > "$ROOT/api/app/__init__.py" <<'PY'
# app package
PY

cat > "$ROOT/api/app/main.py" <<'PY'
from fastapi import FastAPI
from .routers import import as import_router

app = FastAPI(title="Dev Portal API")

# mount minimal router(s)
app.include_router(import_router.router, prefix="", tags=["import"])

@app.get("/health")
def health():
    return {"ok": True}
PY

cat > "$ROOT/api/app/config.py" <<'PY'
from pydantic import BaseSettings

class Settings(BaseSettings):
    DATABASE_URL: str = "postgresql+psycopg2://dev:dev@localhost:5432/devportal"
    ODOO_DATABASE_URL: str = "postgresql+psycopg2://odoo:odoo@localhost:5432/odoo"

settings = Settings()
PY

cat > "$ROOT/api/app/db.py" <<'PY'
# optional: add SQLAlchemy engine/session here later if必要
PY

# --- routers (単数形) ------------------------------------------------------
say "Creating routers..."
cat > "$ROOT/api/app/routers/__init__.py" <<'PY'
from . import import as import  # noqa: F401
PY

# import only (UIで選択→UPSERT) の入口
cat > "$ROOT/api/app/routers/import.py" <<'PY'
from fastapi import APIRouter
router = APIRouter()
# ここに /import/odoo/field /import/odoo/view などのエンドポイントを追加していきます
PY

# プレースホルダ(必要になった時に中身を実装)
for f in portal_model portal_field portal_view portal_smart_button portal_tab portal_menu; do
  cat > "$ROOT/api/app/routers/${f}.py" <<'PY'
# router placeholder (add endpoints later)
from fastapi import APIRouter
router = APIRouter()
PY
done

# --- schemas (単数形) ------------------------------------------------------
say "Creating schemas..."
for f in portal_model portal_field portal_view portal_smart_button portal_tab portal_menu; do
  cat > "$ROOT/api/app/schemas/${f}.py" <<'PY'
# pydantic schemas placeholder
from pydantic import BaseModel

class Placeholder(BaseModel):
    id: int | None = None
PY
done

# --- services(今回は取り込み専用の最小2本) -------------------------------
say "Creating services..."
cat > "$ROOT/api/app/services/odoo_intake.py" <<'PY'
# Odoo標準→ir_*_src を参照して取り込み元を準備する層(後で実装)
PY

cat > "$ROOT/api/app/services/odoo_upsert.py" <<'PY'
# UI選択分を portal_* へUPSERTする層(後で実装)
PY

# --- k8s: dev --------------------------------------------------------------
say "Creating k8s placeholders..."
cat > "$ROOT/k8s/dev/api-configmap.yaml" <<'YAML'
apiVersion: v1
kind: ConfigMap
metadata:
  name: dev-portal-api-config
data: {}
YAML

cat > "$ROOT/k8s/dev/api-deployment.yaml" <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dev-portal-api
spec:
  replicas: 1
  selector: { matchLabels: { app: dev-portal-api } }
  template:
    metadata: { labels: { app: dev-portal-api } }
    spec:
      containers:
        - name: api
          image: dev-portal-api:latest
          ports: [{ containerPort: 8000 }]
YAML

cat > "$ROOT/k8s/dev/api-service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: dev-portal-api }
spec:
  selector: { app: dev-portal-api }
  ports:
    - port: 80
      targetPort: 8000
YAML

cat > "$ROOT/k8s/dev/ingress-api.yaml" <<'YAML'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: dev-portal-api }
spec:
  rules: []
YAML

cat > "$ROOT/k8s/dev/portal_fields-schema-configmap.yaml" <<'YAML'
apiVersion: v1
kind: ConfigMap
metadata:
  name: portal-fields-schema
data: {}
YAML

# --- k8s: infra/postgres ---------------------------------------------------
cat > "$ROOT/k8s/infra/postgres/devportal-db-init-job.yaml" <<'YAML'
apiVersion: batch/v1
kind: Job
metadata: { name: devportal-db-init }
spec: { template: { spec: { restartPolicy: Never } } }
YAML

cat > "$ROOT/k8s/infra/postgres/odoo-db-init-job.yaml" <<'YAML'
apiVersion: batch/v1
kind: Job
metadata: { name: odoo-db-init }
spec: { template: { spec: { restartPolicy: Never } } }
YAML

cat > "$ROOT/k8s/infra/postgres/pg-smoke-job.yaml" <<'YAML'
apiVersion: batch/v1
kind: Job
metadata: { name: pg-smoke }
spec: { template: { spec: { restartPolicy: Never } } }
YAML

cat > "$ROOT/k8s/infra/postgres/service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: postgres }
spec: { ports: [{ port: 5432 }], selector: { app: postgres } }
YAML

cat > "$ROOT/k8s/infra/postgres/statefulset.yaml" <<'YAML'
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: postgres }
spec:
  serviceName: postgres
  selector: { matchLabels: { app: postgres } }
  template:
    metadata: { labels: { app: postgres } }
    spec: { containers: [{ name: postgres, image: postgres:16, ports: [{ containerPort: 5432 }] }] }
YAML

# --- k8s: namespaces & odoo ------------------------------------------------
cat > "$ROOT/k8s/namespaces.yaml" <<'YAML'
apiVersion: v1
kind: Namespace
metadata: { name: dev }
---
apiVersion: v1
kind: Namespace
metadata: { name: infra }
YAML

cat > "$ROOT/k8s/odoo/ingress-odoo.yaml" <<'YAML'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: odoo }
spec: { rules: [] }
YAML

cat > "$ROOT/k8s/odoo/odoo-configmap.yaml" <<'YAML'
apiVersion: v1
kind: ConfigMap
metadata: { name: odoo-config }
data: {}
YAML

cat > "$ROOT/k8s/odoo/odoo-deployment.yaml" <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata: { name: odoo }
spec:
  replicas: 1
  selector: { matchLabels: { app: odoo } }
  template:
    metadata: { labels: { app: odoo } }
    spec: { containers: [{ name: odoo, image: odoo:17, ports: [{ containerPort: 8069 }] }] }
YAML

cat > "$ROOT/k8s/odoo/odoo-init-job.yaml" <<'YAML'
apiVersion: batch/v1
kind: Job
metadata: { name: odoo-init }
spec: { template: { spec: { restartPolicy: Never } } }
YAML

cat > "$ROOT/k8s/odoo/odoo-pvc.yaml" <<'YAML'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: odoo-data }
spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: 5Gi } } }
YAML

cat > "$ROOT/k8s/odoo/odoo-service.yaml" <<'YAML'
apiVersion: v1
kind: Service
metadata: { name: odoo }
spec: { ports: [{ port: 8069 }], selector: { app: odoo } }
YAML

# --- scripts ---------------------------------------------------------------
say "Creating helper scripts..."
cat > "$ROOT/scripts/m1_apply.sh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
kubectl apply -f k8s/namespaces.yaml
kubectl apply -f k8s/infra/postgres/
kubectl apply -f k8s/dev/
kubectl apply -f k8s/odoo/
SH
chmod +x "$ROOT/scripts/m1_apply.sh"

cat > "$ROOT/scripts/m1_delete.sh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
kubectl delete -f k8s/odoo/ --ignore-not-found
kubectl delete -f k8s/dev/ --ignore-not-found
kubectl delete -f k8s/infra/postgres/ --ignore-not-found
kubectl delete -f k8s/namespaces.yaml --ignore-not-found
SH
chmod +x "$ROOT/scripts/m1_delete.sh"

say "Done. Tree bootstrapped."
BASH

chmod +x bootstrap_portal_tree.sh
./bootstrap_portal_tree.sh

了解!作った各ファイルの“役割ひとこと解説”と、前回版をそのまま流用できるかを一目でわかるようにまとめます。
(パスはリポジトリ直下基準)

役割ひとこと&流用可否

パス役割(何をするか)前回そのまま流用可?メモ(パスワード/設定系)
api/DockerfileAPI用コンテナのビルド定義○ 推奨既存のイメージ/レイヤ/apt追加があるなら前回のをそのまま使うのが安全。秘密情報は書かない。
api/requirements.txtPython依存○ 推奨バージョン差で動作が変わるため、前回のままが無難。
api/chromadb.yamlChromaの設定(現フェーズでは未使用可)触らず可。後段の埋め込み/検索で再利用。
api/app/__init__.pyパッケージマーカーどちらでも空でも可。
api/app/main.pyFastAPIエントリ。ルーターをマウント既存のmain.pyがあるなら流用でOK。新ルーター名(routers/import.py 等)だけ合わせればOK。
api/app/config.py環境変数読み込み○(流用推奨)パスワードは.env/K8s Secretから。既存の環境変数名を変えたくない場合、前回のconfig.pyをそのまま使うのが最短。
api/app/db.pyDB接続/Session管理既存の接続初期化(SQLAlchemy 等)をそのまま。新テーブル追加でも接続設定は不変。
api/app/routers/import.pyUIで選んだ ir_*_src取り込みUPSERT新規前回はimports.pyだったなら、残してもOK。新仕様は単数形に寄せる想定。
api/app/routers/portal_*.py各エンティティ用のAPI口(単数形、今は空)新規必要になったら中身実装。前回のfields.py,menus.py等は残して共存でも可。
api/app/schemas/portal_*.pyPydanticスキーマの置き場(今は空)新規後で定義追加。
api/app/services/odoo_intake.py参照用 ir_*_src を読む層新規前回のfile_import.py系は不要。
api/app/services/odoo_upsert.pyportal_* へUPSERTする層新規前回のchroma_*embeddings.py現フェーズ未使用でOK。
k8s/**(dev/infra/odoo)デプロイ/Service/Ingress 等○ 強く推奨パスワードを変えたくないなら前回のYAMLを丸ごと継続。Secret/ConfigMapのキー名もそのまま維持。
k8s/namespaces.yamlNamespace作成既存と同名ならそのまま。
scripts/m1_apply.sh / m1_delete.sh一括 apply / delete前回のままでOK。パスが変わっていなければ編集不要。

補足(パスワードを変えないコツ)

  • コードにパスワードは書かない:前回と同じ K8s Secret / .env をそのまま使い回し。
  • config.py既存の環境変数名のまま読めるように、前回版を流用するのが一番ラク。
    • 例:DATABASE_URL / ODOO_DATABASE_URL など、名前を変えない。
  • Dockerfile/requirements は前回のままが安全(ライブラリ差異で接続ドライバが変わらないように)。

どれを入れ替え・再利用すべきか(実運用のおすすめ)

  • そのまま再利用api/Dockerfileapi/requirements.txtk8s/**scripts/**api/app/db.pyapi/app/config.pyapi/chromadb.yaml
  • 新規・追加api/app/services/odoo_intake.pyapi/app/services/odoo_upsert.pyapi/app/routers/import.py(単数名)
  • 前回から残して共存可(必要なら):routers/ai_enrich.pyservices/chroma_client.py など検索/AI系。今回の「取り込みだけ」フェーズでは未使用なので無改修でOK。

最低限の差分だけで動かすチェックリスト

  • 前回のDockerfile/requirementsをそのまま使う
  • 前回のK8s Secret/ConfigMap(DB/ODOOの接続情報)をそのまま使う
  • config.py前回版を流用(環境変数名変更なし)
  • 新DDLで portal_* を作成(前回DBをそのまま使用)
  • routers/import.py + services/odoo_* を追加して main.py にマウント
  • /health が通ることを確認 → POST /import/... で取り込み試験

必要なら、前回のmain.pyに最小差分で新ルーターだけ追加するパッチ例も出せます。パスワード類は一切触らずに進められますよ。


Comments

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です