# Build image riêng cho ứng dụng và cấu hình để PaaS chạy

Skill cha: [tdshift-paas](https://app.tdshift.info/skill) · Cần: Personal API Token ([tai-khoan](https://app.tdshift.info/skill/tai-khoan.md)) · Tham chiếu API: [agent-api](https://app.tdshift.info/skill/agent-api.md)

## Nguyên tắc

- **Mỗi ứng dụng có image riêng**, chứa sẵn mã nguồn và thư viện của ứng dụng. Không deploy ứng dụng của người dùng bằng image chung (`nginx`, `node`, `python`, `odoo:18`, `wordpress`...) rồi mong chép code vào sau: PaaS không cho bind mount, không build, nên image chung sẽ chạy trang trống hoặc trang mặc định.
- Image chung chỉ dùng cho **service phụ không có code riêng**: `postgres:17`, `redis:7`, `mariadb:11`...
- Image được **GitLab CI/CD build và push** lên registry (thường là GitLab Container Registry). PaaS chỉ pull và chạy.
- Tag image theo commit (`$CI_COMMIT_SHORT_SHA`), không dùng `latest`, để biết chính xác bản nào đang chạy và rollback được.

## Bước 1 - Dockerfile trong repo ứng dụng

Yêu cầu để chạy được trên PaaS:

| Yêu cầu | Lý do |
|---|---|
| Chép mã nguồn và cài thư viện ngay trong image (`COPY . .`, `RUN npm ci` / `pip install` / `composer install`) | PaaS không mount code vào container |
| Ứng dụng lắng nghe HTTP trên `0.0.0.0:<PORT>` cố định (ví dụ 8080) | Traefik gọi vào cổng này; đây là **port** khai báo cho PaaS |
| Có một đường dẫn trả 2xx/3xx không cần đăng nhập (ví dụ `/healthz` hoặc `/`) | Dùng làm **health_path** |
| Cấu hình đọc từ biến môi trường (`DATABASE_URL`, `APP_KEY`...) | Secret điền qua API `/env`, không nằm trong image |
| Dữ liệu cần giữ (upload, file sinh ra) ghi vào một thư mục cố định | Thư mục này được gắn named volume trong Compose |
| Không cần `ports`, `privileged`, quyền root đặc biệt | Các tuỳ chọn này bị PaaS từ chối |

Ví dụ Node.js (đổi theo ngôn ngữ thật của dự án, không tự bịa lệnh build):

```dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV PORT=8080
EXPOSE 8080
USER node
CMD ["node", "server.js"]
```

Ví dụ Python:

```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY . .
EXPOSE 8080
CMD ["gunicorn", "-b", "0.0.0.0:8080", "app:app"]
```

## Bước 2 - `.gitlab-ci.yml` build và push

```yaml
stages: [build]

build-image:
  stage: build
  image: docker:27
  services: [docker:27-dind]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
```

`docker:27` ở đây chỉ là image của **runner CI** để chạy lệnh build, không phải image ứng dụng. Image ứng dụng là `$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA`, ví dụ `registry.gitlab.com/<group>/<repo>:3f2a1b9c`. Lấy đúng giá trị này từ log job hoặc GitLab → Deploy → Container Registry; không tự đoán tag.

## Bước 3 - Quyền pull cho PaaS (repo private)

GitLab → Settings → Repository → **Deploy tokens** → tạo token, scope `read_registry`. Ghi lại username (`gitlab+deploy-token-...`) và token. Người dùng tự tạo; AI không yêu cầu dán token vào chat nếu có cách truyền an toàn (biến môi trường của shell).

## Bước 4 - AI tự điền cấu hình bằng Agent API

```bash
export PAAS_URL=https://app.tdshift.info
AUTH="Authorization: Bearer $PAAS_API_TOKEN"; JSON='Content-Type: application/json'
```

1. Xác minh token: `curl -fsS "$PAAS_URL/api/v1/agent/projects" -H "$AUTH"` → 200.
2. Tạo project (một lần), lưu `id`:
   `curl -fsS -X POST "$PAAS_URL/api/v1/agent/projects" -H "$AUTH" -H "$JSON" --data '{"name":"my-app","type":"compose"}'`
3. Registry (image private):
   `PUT /api/v1/agent/projects/$UUID/registry` body `{"registry":"registry.gitlab.com","username":"<DEPLOY_USER>","token":"<DEPLOY_TOKEN>"}` → `configured:true`.
4. Biến môi trường (gửi nhiều biến một lần được):
   `PUT .../env` body `{"variables":{"APP_ENV":"production","DATABASE_URL":"postgres://app:<PASS>@db:5432/app"}}`. Không in giá trị bí mật ra output.
5. Compose dùng image riêng, khai báo web service, port, health path:
   ```yaml
   services:
     web:
       image: registry.gitlab.com/<group>/<repo>:<sha>
       restart: unless-stopped
       environment:
         APP_ENV: ${APP_ENV}
         DATABASE_URL: ${DATABASE_URL}
       volumes:
         - {type: volume, source: uploads, target: /app/uploads}
       depends_on: [db]
     db:
       image: postgres:17
       restart: unless-stopped
       environment:
         POSTGRES_DB: app
         POSTGRES_USER: app
         POSTGRES_PASSWORD: ${DB_PASSWORD}
       volumes:
         - {type: volume, source: pgdata, target: /var/lib/postgresql/data}
   volumes:
     uploads: {}
     pgdata: {}
   ```
   `PUT .../compose` body `{"compose":"<YAML trên>","web_service":"web","port":8080,"health_path":"/healthz"}` → `saved:true`, `deployed:false`. Quy tắc Compose: [deploy-compose](https://app.tdshift.info/skill/deploy-compose.md).
6. Đối chiếu: `GET .../configuration` → `web_service`, `port`, `health_path`, `images`, `registry_configured`, `environment_keys` đúng như mong muốn.
7. Deploy (chỉ khi người dùng đã yêu cầu chạy ứng dụng): `POST .../deploy` → 202 `status:"updating"`.
8. Theo dõi: `GET /api/v1/agent/projects/$UUID` mỗi 10 giây (tối đa khoảng 10 phút) cho tới khi `status` khác `updating`.
   - `ready` và `error` = `null`: deploy xong. Báo người dùng mở địa chỉ project hiển thị trên trang `https://app.tdshift.info/projects/$UUID` để kiểm tra.
   - `ready` và `error` có nội dung: deploy lỗi, cấu hình cũ đã được khôi phục. Đọc `error`, sửa đúng nguyên nhân, không deploy lặp lại khi chưa sửa gì.

## Cập nhật phiên bản mới

Sau mỗi lần CI push tag mới:

1. `PUT .../image` body `{"image":"registry.gitlab.com/<group>/<repo>:<sha mới>"}` (mặc định đổi image của web service; service khác thì thêm `"service":"<tên>"`). Endpoint ghi lại YAML nên comment trong bản nháp bị mất.
2. `POST .../deploy`, rồi theo dõi như bước 8.

Rollback: `PUT .../image` với tag cũ rồi `POST .../deploy`.

Muốn CI tự làm hai bước trên trong **một** lệnh gọi: dùng Deployment API `POST https://app.tdshift.info/api/v1/projects/<uuid>/deploy` với Personal API Token của chủ project và body `{"image":"..."}` (job mẫu trong [ci-cd-gitlab](https://app.tdshift.info/skill/ci-cd-gitlab.md)). Lưu token vào GitLab CI/CD Variables dạng **masked + protected**. Token này có quyền trên **mọi project của tài khoản**; chỉ làm khi người dùng đồng ý.

## Lỗi thường gặp

| Dấu hiệu | Nguyên nhân | Cách xử lý |
|---|---|---|
| `error` có "pull" / "unauthorized" / "not found" | Sai tag, chưa push, hoặc thiếu/sai registry | Kiểm tra tag trong Container Registry; gọi lại `/registry` với deploy token `read_registry` |
| `error` về health check | Ứng dụng không lắng nghe đúng `port`, hoặc `health_path` trả 4xx/5xx | Sửa `port`/`health_path` bằng `PUT .../compose`, hoặc sửa ứng dụng rồi build lại |
| Trang trắng / trang mặc định nginx | Dùng image chung, code không có trong image | Build image riêng theo Bước 1–2 |
| 409 `Save Compose with PUT /compose first` | Chưa lưu Compose qua API hoặc UI | Làm bước 5 |
| 409 `Project is busy` | Đang deploy | Chờ, đọc lại trạng thái; không gửi lặp |
| 422 `Invalid Compose` | Image sai định dạng hoặc Compose vi phạm quy tắc | Đọc `error`, sửa đúng chỗ |
