diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index cc38a65c..395b8239 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -400,6 +400,21 @@ "description": "Delegate tasks to specialist AI agents via the HOL Registry, plan, find, summon, and recover sessions.", "icon": "./plugins/hashgraph-online/registry-broker-codex-plugin/assets/icon.png" }, + { + "name": "sealos", + "displayName": "Sealos", + "source": { + "source": "local", + "path": "./plugins/labring/sealos-skills" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Development & Workflow", + "description": "Deploy apps to Sealos Cloud from Codex with readiness checks, Dockerfile generation, Compose conversion, image builds, and rollout updates.", + "icon": "./plugins/labring/sealos-skills/assets/logo.svg" + }, { "name": "session-orchestrator", "displayName": "Session Orchestrator", diff --git a/README.md b/README.md index 1c40299f..61807740 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Third-party plugins built by the community. [PRs welcome](#contributing)! - [Praxis](https://github.com/ouonet/praxis) - Intent-driven workflow skills for coding agents: describe what done looks like, not the steps. Triage-first design keeps token costs low across design, TDD, debug, review, and release. - [Project Autopilot](https://github.com/AlexMi64/codex-project-autopilot) - Turn an idea into a structured project workflow with planning, execution, verification, and handoff. - [Registry Broker](https://github.com/hashgraph-online/registry-broker-codex-plugin) - Delegate tasks to specialist AI agents via the HOL Registry, plan, find, summon, and recover sessions. +- [Sealos](https://github.com/labring/sealos-skills) - Deploy apps to Sealos Cloud from Codex with readiness checks, Dockerfile generation, Compose conversion, image builds, and rollout updates. - [Secret Guard](./plugins/mturac/secret-guard) - Pre-commit secret scanner using pattern and entropy detection. - [Session Orchestrator](https://github.com/Kanevry/session-orchestrator) - Session orchestration for Claude Code, Codex, and Cursor IDE — structured planning, wave-based execution, VCS integration (GitLab + GitHub), quality gates, and clean session close-out with issue tracking. - [Spec-Driven Development](https://github.com/Habib0x0/spec-driven-plugin) - Three-phase Requirements → Design → Tasks workflow for Claude Code and Codex — EARS notation acceptance criteria, autonomous execution loop, cross-spec dependencies, and post-implementation acceptance testing. diff --git a/plugins.json b/plugins.json index b25f6715..c02bd558 100644 --- a/plugins.json +++ b/plugins.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "name": "awesome-codex-plugins", "version": "1.0.0", - "last_updated": "2026-05-20", - "total": 82, + "last_updated": "2026-05-22", + "total": 83, "categories": [ "Development & Workflow", "Tools & Integrations" @@ -279,6 +279,16 @@ "source": "awesome-codex-plugins", "install_url": "https://raw.githubusercontent.com/hashgraph-online/registry-broker-codex-plugin/HEAD/.codex-plugin/plugin.json" }, + { + "name": "Sealos", + "url": "https://github.com/labring/sealos-skills", + "owner": "labring", + "repo": "sealos-skills", + "description": "Deploy apps to Sealos Cloud from Codex with readiness checks, Dockerfile generation, Compose conversion, image builds, and rollout updates.", + "category": "Development & Workflow", + "source": "awesome-codex-plugins", + "install_url": "https://raw.githubusercontent.com/labring/sealos-skills/HEAD/.codex-plugin/plugin.json" + }, { "name": "Session Orchestrator", "url": "https://github.com/Kanevry/session-orchestrator", diff --git a/plugins/labring/sealos-skills/.agents/plugins/marketplace.json b/plugins/labring/sealos-skills/.agents/plugins/marketplace.json new file mode 100644 index 00000000..02278d19 --- /dev/null +++ b/plugins/labring/sealos-skills/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "sealos", + "interface": { + "displayName": "Sealos" + }, + "plugins": [ + { + "name": "sealos", + "source": { + "source": "local", + "path": "./" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Coding" + } + ] +} diff --git a/plugins/labring/sealos-skills/.codex-plugin/plugin.json b/plugins/labring/sealos-skills/.codex-plugin/plugin.json new file mode 100644 index 00000000..f012a67b --- /dev/null +++ b/plugins/labring/sealos-skills/.codex-plugin/plugin.json @@ -0,0 +1,48 @@ +{ + "name": "sealos", + "version": "0.1.0", + "description": "Sealos deployment and app-building skills for Codex.", + "author": { + "name": "Sealos", + "url": "https://sealos.io/" + }, + "homepage": "https://github.com/labring/sealos-skills", + "repository": "https://github.com/labring/sealos-skills", + "license": "MIT", + "keywords": [ + "sealos", + "sealos-cloud", + "deployment", + "docker", + "kubernetes", + "codex", + "skills", + "devops", + "desktop-apps" + ], + "skills": "./skills/", + "interface": { + "displayName": "Sealos", + "shortDescription": "Deploy projects to Sealos Cloud from Codex", + "longDescription": "Use Sealos in Codex to assess cloud-native readiness, generate production Dockerfiles, build or reuse container images, convert Docker Compose services into Sealos templates, deploy and update workloads on Sealos Cloud, and build Sealos Desktop apps with SDK integration guidance. The plugin bundles Sealos Agent Skills under ./skills/ and is invoked as $sealos in Codex or /sealos in Claude Code-compatible hosts.", + "developerName": "Sealos", + "category": "Coding", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "websiteURL": "https://sealos.io/", + "privacyPolicyURL": "https://sealos.io/docs/msa/privacy-policy/", + "termsOfServiceURL": "https://sealos.io/docs/msa/terms-of-service/", + "defaultPrompt": [ + "Deploy this repo to Sealos Cloud.", + "Containerize this app and deploy it to Sealos.", + "Build a Sealos Desktop app starter." + ], + "brandColor": "#15B8A6", + "composerIcon": "./assets/logo.svg", + "logo": "./assets/logo.svg", + "screenshots": [] + } +} diff --git a/plugins/labring/sealos-skills/.codexignore b/plugins/labring/sealos-skills/.codexignore new file mode 100644 index 00000000..b067d110 --- /dev/null +++ b/plugins/labring/sealos-skills/.codexignore @@ -0,0 +1,8 @@ +.git/ +.github/ +.sealos/ +node_modules/ +dist/ +out/ +.next/ +*.log diff --git a/plugins/labring/sealos-skills/README.md b/plugins/labring/sealos-skills/README.md new file mode 100644 index 00000000..b7ed8718 --- /dev/null +++ b/plugins/labring/sealos-skills/README.md @@ -0,0 +1,207 @@ +# Sealos Skills + +Deploy projects to [Sealos Cloud](https://sealos.io) from your AI agent. + +Sealos Skills is a plugin-first skill pack centered on Sealos Cloud development and deployment. It helps an AI agent inspect a project, prepare missing deployment artifacts, connect Sealos Cloud databases for development, build or reuse a container image, ship the app to Sealos Cloud, and view deployed resources in a local read-only canvas. + +The recommended way to use it is as an agent plugin installed with [`npx plugins`](https://www.npmjs.com/package/plugins). The same root `skills/` directory also remains compatible with `skills.sh` and context-only extension hosts such as Gemini CLI and Qwen Code. + +## Quick Start + +### Recommended: install as a plugin + +Install the Sealos plugin into Codex: + +```bash +npx plugins add https://github.com/labring/sealos-skills --target codex +``` + +Install the Sealos plugin into Claude Code: + +```bash +npx plugins add https://github.com/labring/sealos-skills --target claude-code +``` + +If you only use one detected agent tool on the machine, you can let `plugins` choose the target: + +```bash +npx plugins add https://github.com/labring/sealos-skills +``` + +After installation, use the plugin from your agent: + +- **Codex CLI:** type `$sealos` +- **Codex App:** click the **+** button in the lower-left corner of the chat input, choose **Plugins**, then choose **Sealos** +- **Claude Code:** type `/sealos` + +![Select the Sealos plugin in Codex App](./assets/codex-sealos.png) + +Plugin examples: + +```text +$sealos deploy this repo to Sealos Cloud +$sealos deploy /path/to/project +$sealos deploy https://github.com/labring-sigs/kite +$sealos create a cloud Postgres database for this repo and wire DATABASE_URL +``` + +For Claude Code, use the same requests with `/sealos`: + +```text +/sealos deploy this repo to Sealos Cloud +/sealos deploy /path/to/project +/sealos deploy https://github.com/labring-sigs/kite +/sealos create a cloud Postgres database for this repo and wire DATABASE_URL +``` + +In Codex App, select **Sealos** from **Plugins**, then describe what you want to deploy. + +### Other supported AI tools + +| Tool | Install | Usage | +| --- | --- | --- | +| Codex CLI / Codex App | `npx plugins add https://github.com/labring/sealos-skills --target codex` | `$sealos` in Codex CLI, or **+** → **Plugins** → **Sealos** in Codex App | +| Claude Code | `npx plugins add https://github.com/labring/sealos-skills --target claude-code` | `/sealos` | +| Claude Code marketplace flow | `/plugin marketplace add labring/sealos-skills` | `/sealos` | +| OpenClaw / ClawHub | `clawhub install labring/sealos-skills` | Host command exposure depends on the ClawHub runtime | +| CodeBuddy | `/plugin marketplace add labring/sealos-skills` | Host command exposure depends on the CodeBuddy runtime | +| Gemini CLI | `gemini extensions install https://github.com/labring/sealos-skills` | Context-only extension; ask Gemini to use Sealos Skills | +| Qwen Code | `qwen extensions install https://github.com/labring/sealos-skills` | Context-only extension; ask Qwen to use Sealos Skills | +| Amp / Kimi / generic repo importers | Import `https://github.com/labring/sealos-skills.git` | Host-dependent | + +Gemini CLI and Qwen Code manifests provide repository context through `CLAUDE.md`; they do not claim slash-command support. + +### Alternative: install as a `skills.sh` skill pack + +If your agent uses `skills.sh` directly, install the same skills pack with: + +```bash +npx skills add labring/sealos-skills +``` + +Then run the deploy skill directly: + +```text +/sealos-deploy +/sealos-deploy /path/to/project +/sealos-deploy https://github.com/labring-sigs/kite +``` + +After a project has been deployed, run a local Sealos resource canvas UI: + +```text +/sealos-canvas +``` + +`/sealos-deploy` is the direct `skills.sh` skill entry. Plugin usage should go through `$sealos` in Codex or `/sealos` in Claude Code. + +## Why Use the Plugin + +Prefer the plugin install for Codex and Claude Code because it: + +- installs all Sealos skills as one managed package +- exposes the same skills across supported agent tools +- keeps the plugin metadata, logo, prompts, commands, and capabilities together +- avoids maintaining a separate packaged copy of the skills + +## Plugin Distribution + +The Codex integration follows [OpenAI's Codex plugin build guide](https://developers.openai.com/codex/plugins/build): + +- `.codex-plugin/plugin.json` contains plugin identity, discovery metadata, interface copy, default prompts, brand metadata, and asset paths relative to the repository root. +- `.agents/plugins/marketplace.json` registers this repo-local plugin for local Codex marketplace testing. +- `distribution/platforms.json` records platform support claims and evidence. +- `marketplaces/README.md` owns marketplace rules and prevents command-support overclaims. +- `scripts/validate-codex-plugin.py` validates the Codex manifest, repo marketplace, platform registry, and asset paths. +- `skills/**/SKILL.md` remains the only skill source; do not add a second packaged copy of the skills. + +Validate plugin metadata before publishing or pushing manifest changes: + +```bash +python3 scripts/validate-codex-plugin.py +python3 -m json.tool .codex-plugin/plugin.json >/dev/null +python3 -m json.tool .agents/plugins/marketplace.json >/dev/null +python3 -m json.tool distribution/platforms.json >/dev/null +``` + +## How Setup Works + +You only need a plugin-compatible or `skills.sh` compatible AI agent and a project to deploy. + +During the deploy and database flows, Sealos Skills will: + +- check whether tools such as Docker and `kubectl` are available +- guide the user through Sealos login when needed +- use `sealos-cli` for Sealos Cloud database creation, connection details, and database operations +- use or help prepare a container registry path such as Docker Hub or GHCR + +For an actual deployment, you will still need a Sealos Cloud account and access to a container registry, but these do not need to be fully set up before the skill starts. For database work, you need a Sealos Cloud account and a workspace that can create database resources. + +## What Sealos Deploy Handles + +On a typical deploy, the agent will: + +- assess the project structure and runtime needs +- reuse an existing image or build one when needed +- generate a Sealos template +- deploy and verify rollout + +Later runs can switch to an in-place update flow when an existing deployment is detected. + +## What Sealos Database Handles + +For a local project or Devbox that needs a cloud database, the agent will: + +- detect database signals such as `DATABASE_URL`, Prisma, Drizzle, MongoDB, MySQL, or Redis +- use `sealos-cli database` to list, create, inspect, and connect Sealos Cloud databases +- write only the required local env key without exposing secrets in chat +- verify the app's real database path through migrations, introspection, or startup checks +- manage public access only after confirmation + +## What Sealos Canvas Handles + +For a repository already deployed by `/sealos-deploy`, the agent will: + +1. Read `.sealos/state.json` to locate the deployed app. +2. Query the Sealos namespace with read-only `kubectl get` commands. +3. Start a temporary `127.0.0.1` canvas UI. +4. Output and open the local UI address for inspection. + +If the project has not been deployed yet, `/sealos-canvas` stops and tells the user to run `/sealos-deploy` first. + +## Included Skills + +The plugin and `skills.sh` pack expose the same skill source: + +- `sealos-deploy` — deploy a local or GitHub project to Sealos Cloud +- `sealos-database` — create, connect, and operate Sealos Cloud databases for development +- `sealos-canvas` — view deployed Sealos resources in a local read-only canvas UI +- `sealos-app-builder` — build Sealos Desktop apps with SDK integration +- `cloud-native-readiness` — assess deployment readiness +- `dockerfile-skill` — generate production-ready Dockerfiles +- `docker-to-sealos` — convert Docker Compose services into Sealos templates + +## Repository + +[`skills/`](./skills) is the single source of truth for Sealos deploy, Sealos canvas, and the supporting skills used during the deploy flow. The same root-level skills directory serves `skills.sh` installs and every plugin or extension manifest in this repository. + +Important distribution files: + +- [`.codex-plugin/plugin.json`](./.codex-plugin/plugin.json) — Codex plugin manifest +- [`.agents/plugins/marketplace.json`](./.agents/plugins/marketplace.json) — local Codex marketplace entry +- [`.claude-plugin/plugin.json`](./.claude-plugin/plugin.json) — Claude Code-compatible plugin manifest +- [`marketplace.json`](./marketplace.json) and [`.claude-plugin/marketplace.json`](./.claude-plugin/marketplace.json) — Claude-compatible marketplace entries +- [`.codebuddy-plugin/marketplace.json`](./.codebuddy-plugin/marketplace.json) — CodeBuddy marketplace entry +- [`gemini-extension.json`](./gemini-extension.json) — Gemini CLI context extension +- [`qwen-extension.json`](./qwen-extension.json) — Qwen Code context extension +- [`openclaw.plugin.json`](./openclaw.plugin.json) — OpenClaw / ClawHub bundle pointer +- [`commands/sealos.md`](./commands/sealos.md) — `/sealos` plugin command entry for compatible hosts +- [`distribution/platforms.json`](./distribution/platforms.json) — platform support registry +- [`marketplaces/README.md`](./marketplaces/README.md) — marketplace rules and support-claim ownership +- [`scripts/validate-codex-plugin.py`](./scripts/validate-codex-plugin.py) — Codex plugin validation + +Do not add a second packaged copy of the skills. Root `skills/**` is the only skill source for all installation paths. + +## License + +MIT diff --git a/plugins/labring/sealos-skills/assets/codex-sealos.png b/plugins/labring/sealos-skills/assets/codex-sealos.png new file mode 100644 index 00000000..82b7eb70 Binary files /dev/null and b/plugins/labring/sealos-skills/assets/codex-sealos.png differ diff --git a/plugins/labring/sealos-skills/assets/logo.svg b/plugins/labring/sealos-skills/assets/logo.svg new file mode 100644 index 00000000..2b3fcc9d --- /dev/null +++ b/plugins/labring/sealos-skills/assets/logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/labring/sealos-skills/skills/cloud-native-readiness/SKILL.md b/plugins/labring/sealos-skills/skills/cloud-native-readiness/SKILL.md new file mode 100644 index 00000000..0b59da9b --- /dev/null +++ b/plugins/labring/sealos-skills/skills/cloud-native-readiness/SKILL.md @@ -0,0 +1,161 @@ +--- +name: cloud-native-readiness +description: Assess whether a project is ready for cloud-native deployment. Evaluates statelessness, config, scalability, and produces a readiness score (0-12). Use when user asks about containerization readiness, Docker/Kubernetes compatibility, deployment feasibility, whether their app can run in containers or the cloud, or wants a pre-deployment assessment. Also triggers on "/cloud-native-readiness". +--- + +# Cloud Native Readiness Assessment Skill + +## Overview + +This skill evaluates a repository's readiness for cloud-native microservice deployment through a 3-phase workflow: + +1. **Assess** - Analyze the project against cloud-native criteria and produce a readiness report +2. **Detect** - Check if Docker artifacts already exist (Dockerfile, docker-compose, container images) +3. **Route** - If artifacts exist, return the result directly; if not, invoke `dockerfile-skill` to containerize + +## Workflow + +``` +cloud-native-readiness + │ + ├─ Phase 1: Cloud-Native Assessment + │ ├─ NOT suitable → Report reasons, suggest remediation, END + │ └─ Suitable → Continue + │ + ├─ Phase 2: Existing Artifacts Detection + │ ├─ Found Dockerfile/docker-compose/image → Report existing setup, END + │ └─ Not found → Continue + │ + └─ Phase 3: Route to dockerfile-skill + └─ Invoke /dockerfile to generate Docker configuration +``` + +## Usage + +``` +/cloud-native-readiness # Assess current directory +/cloud-native-readiness # Assess specific path +/cloud-native-readiness # Clone and assess +``` + +## Quick Start + +When invoked, ALWAYS follow this sequence: + +1. Read and execute [modules/assess.md](modules/assess.md) — Cloud-native readiness evaluation +2. Read and execute [modules/detect.md](modules/detect.md) — Existing Docker artifacts detection +3. Read and execute [modules/route.md](modules/route.md) — Decision routing + +## Phase 1: Cloud-Native Readiness Assessment + +Load and execute: [modules/assess.md](modules/assess.md) + +**Evaluates 6 dimensions** (each scored 0-2): + +| Dimension | What to check | +|-----------|---------------| +| Statelessness | Does the app store state locally (sessions in memory, local file writes)? | +| Config Externalization | Are configs hardcoded or driven by env vars / config files? | +| Horizontal Scalability | Can multiple instances run without conflicts? | +| Startup/Shutdown | Does the app start fast and handle SIGTERM gracefully? | +| Observability | Does it have health checks, structured logging, metrics? | +| Service Boundaries | Is it a focused service or a tightly-coupled monolith? | + +**Scoring**: +- **10-12**: Excellent — fully cloud-native ready +- **7-9**: Good — ready with minor adjustments +- **4-6**: Fair — needs some refactoring before containerization +- **0-3**: Poor — significant rework needed, not recommended for containerization now + +**Output**: Structured readiness report with score, findings, and recommendations. + +## Phase 2: Existing Artifacts Detection + +Load and execute: [modules/detect.md](modules/detect.md) + +**Checks for**: +- `Dockerfile` / `Dockerfile.*` (multi-stage, multi-service) +- `docker-compose.yml` / `docker-compose.yaml` / `compose.yml` +- `.dockerignore` +- `DOCKER.md` or docker-related documentation +- Container registry references (ghcr.io, docker.io, ECR, GCR, ACR) +- Kubernetes manifests (`k8s/`, `kubernetes/`, `deploy/`, `helm/`, `charts/`) +- CI/CD pipeline with Docker build steps (`.github/workflows/`, `.gitlab-ci.yml`) + +**Output**: Inventory of existing Docker/K8s artifacts with quality assessment. + +## Phase 3: Routing Decision + +Load and execute: [modules/route.md](modules/route.md) + +**Decision Matrix**: + +| Readiness Score | Artifacts Exist | Action | +|-----------------|-----------------|--------| +| ≥ 7 | Yes, complete | Report existing setup. Done. | +| ≥ 7 | Yes, partial | Report gaps, suggest improvements. Done. | +| ≥ 7 | No | Invoke `dockerfile-skill` to generate. | +| 4-6 | Any | Report issues + remediation steps. Optionally proceed with `dockerfile-skill`. | +| 0-3 | Any | Report blockers. Do NOT invoke `dockerfile-skill`. | + +## Readiness Report Format + +The final output MUST use this format: + +```markdown +# Cloud-Native Readiness Report + +## Summary +- **Project**: {name} +- **Score**: {score}/12 ({rating}) +- **Verdict**: {Ready | Ready with caveats | Needs work | Not recommended} + +## Assessment Details + +### ✅ Strengths +- {what's already cloud-native friendly} + +### ⚠️ Concerns +- {issues that need attention} + +### ❌ Blockers (if any) +- {critical issues preventing containerization} + +## Dimension Scores + +| Dimension | Score | Notes | +|-----------|-------|-------| +| Statelessness | {0-2} | {detail} | +| Config Externalization | {0-2} | {detail} | +| Horizontal Scalability | {0-2} | {detail} | +| Startup/Shutdown | {0-2} | {detail} | +| Observability | {0-2} | {detail} | +| Service Boundaries | {0-2} | {detail} | + +## Existing Docker Artifacts +- {inventory or "None found"} + +## Recommendation +- {next steps} +``` + +## Supporting Resources + +- **Assessment Criteria**: [knowledge/criteria.md](knowledge/criteria.md) — Detailed scoring rubrics +- **Anti-Patterns**: [knowledge/anti-patterns.md](knowledge/anti-patterns.md) — Common cloud-native anti-patterns +- **Examples**: [examples/](examples/) — Sample readiness reports + +## Integration with dockerfile-skill + +When routing to `dockerfile-skill`, pass the assessment context: + +1. The readiness report findings inform Dockerfile generation decisions +2. Detected external services map directly to `docker-compose.yml` services +3. Identified concerns become Dockerfile comments / `DOCKER.md` caveats +4. The assessment's config externalization findings drive ENV/ARG setup + +**Handoff**: When invoking `dockerfile-skill`, include a summary of: +- Detected language/framework/package manager +- External service dependencies +- Config externalization status +- Any special concerns (stateful components, long startup, etc.) diff --git a/plugins/labring/sealos-skills/skills/cloud-native-readiness/examples/sample-report.md b/plugins/labring/sealos-skills/skills/cloud-native-readiness/examples/sample-report.md new file mode 100644 index 00000000..83e77ad1 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/cloud-native-readiness/examples/sample-report.md @@ -0,0 +1,54 @@ +# Cloud-Native Readiness Report — Sample + +## Summary +- **Project**: marble (headless CMS) +- **Score**: 11/12 (Excellent) +- **Verdict**: Ready + +## Assessment Details + +### Strengths +- All data stored in external PostgreSQL (Neon serverless) +- Session management via Better Auth with DB-backed sessions +- File uploads to Cloudflare R2 (cloud object storage) +- Config fully driven by environment variables with `.env.example` +- Clear monorepo structure with independent deployable units +- Hono API is edge-first and stateless by design +- Redis-based rate limiting and caching via Upstash + +### Concerns +- No explicit SIGTERM handler detected in API (Hono handles it via runtime) + +### Blockers +- None + +## Dimension Scores + +| Dimension | Score | Notes | +|-----------|-------|-------| +| Statelessness | 2/2 | PostgreSQL + R2 + Redis. No local state. | +| Config Externalization | 2/2 | All env vars, .env.example present, validation in place. | +| Horizontal Scalability | 2/2 | Stateless API, Redis-backed rate limits, no file locks. | +| Startup/Shutdown | 1/2 | Hono is fast but no explicit health endpoint or SIGTERM handler. | +| Observability | 2/2 | Analytics middleware, error handling, logging to stdout. | +| Service Boundaries | 2/2 | Clear apps/ separation: api, cms, web. Independent package.json. | + +## Per-Unit Assessment + +| Unit | Path | Type | Cloud-Native Ready | +|------|------|------|--------------------| +| api | apps/api | Hono REST API | Yes — stateless, edge-first | +| cms | apps/cms | Next.js dashboard | Yes — standalone mode supported | +| web | apps/web | Astro static site | Yes — can serve via CDN or container | + +## Existing Docker Artifacts +- `docker-compose.yml` found at root (for local Postgres) +- No Dockerfile found for any app +- No Kubernetes manifests +- No CI/CD Docker build steps + +## Recommendation +- Project is fully cloud-native ready (score 11/12) +- Docker Compose exists for local dev but no production Dockerfiles +- **Next step**: Invoke `dockerfile-skill` to generate production Docker configuration +- Minor suggestion: Add `/health` endpoint to API for K8s readiness probes diff --git a/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/anti-patterns.md b/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/anti-patterns.md new file mode 100644 index 00000000..460f8965 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/anti-patterns.md @@ -0,0 +1,131 @@ +# Cloud-Native Anti-Patterns + +Common patterns that indicate a project is NOT ready for containerization. + +## Critical Anti-Patterns (Blockers) + +### 1. Local File Storage for User Data +``` +Problem: User uploads saved to ./uploads/ or ./data/ +Impact: Data lost on container restart, can't scale horizontally +Fix: Migrate to S3/R2/GCS for file storage +``` + +### 2. SQLite as Primary Database +``` +Problem: SQLite file stored on local filesystem +Impact: Can't share between containers, data lost on restart without volume +Fix: Migrate to PostgreSQL/MySQL with external connection +``` + +### 3. Hardcoded Secrets in Source +``` +Problem: API keys, passwords, tokens committed to git +Impact: Security risk, can't rotate without code change +Fix: Move all secrets to environment variables +``` + +### 4. Hardcoded localhost References +``` +Problem: Code references localhost:5432, 127.0.0.1:6379 +Impact: Won't work in container network +Fix: Use env vars for all service URLs (DATABASE_URL, REDIS_URL) +``` + +### 5. Process-Dependent State +``` +Problem: Global variables storing user sessions, request counts +Impact: State lost on restart, inconsistent across instances +Fix: Externalize to Redis or database +``` + +## Warning Anti-Patterns (Concerns) + +### 6. In-Memory Session Store +``` +Problem: express-session with default MemoryStore +Impact: Sessions lost on restart, can't load-balance across instances +Fix: Use Redis session store (connect-redis) +``` + +### 7. Cron Jobs Without Distributed Lock +``` +Problem: node-cron or setInterval for scheduled tasks +Impact: Multiple instances = multiple executions +Fix: Use distributed scheduler (BullMQ, database-backed, leader election) +``` + +### 8. WebSocket Without Redis Adapter +``` +Problem: Socket.IO or ws without pub/sub backing +Impact: Clients on different instances can't communicate +Fix: Add Redis adapter for Socket.IO, or use external pub/sub +``` + +### 9. Large Startup Payload +``` +Problem: Loading large ML models, data files, or indexes at startup +Impact: Slow container startup, fails K8s readiness probes +Fix: Lazy loading, separate model-serving service, readiness probe with delay +``` + +### 10. No Graceful Shutdown +``` +Problem: No SIGTERM handler, abrupt process exit +Impact: In-flight requests dropped, database connections leaked +Fix: Add signal handler, drain connections, close DB pool +``` + +### 11. Logging to Files +``` +Problem: Winston/Bunyan configured to write to ./logs/app.log +Impact: Logs lost on container restart, fills up container filesystem +Fix: Log to stdout/stderr, use container log driver for aggregation +``` + +### 12. Build-Time Secrets Required +``` +Problem: Next.js SSG pages need DATABASE_URL at build time +Impact: Secrets must be available during docker build (leaks into layers) +Fix: Use ARG with placeholder values for build, real values at runtime +``` + +## Informational Anti-Patterns (Notes) + +### 13. Monolith Without Clear Boundaries +``` +Problem: Single process handles API, background jobs, WebSocket, cron +Impact: Can't scale components independently +Note: Works in containers but limits K8s benefits +Suggestion: Consider splitting into services over time +``` + +### 14. Shared Database Without Scoping +``` +Problem: Multiple services access same tables directly +Impact: Schema changes require coordinated deployment +Note: Common and acceptable for many projects +Suggestion: Define clear table ownership per service +``` + +### 15. Missing Health Checks +``` +Problem: No /health or /healthz endpoint +Impact: K8s can't determine if container is healthy +Fix: Add simple health endpoint that checks DB connectivity +``` + +## Detection Cheat Sheet + +| Anti-Pattern | Search Pattern | +|-------------|---------------| +| Local file storage | `fs.write`, `multer.diskStorage`, `./uploads` | +| SQLite | `sqlite`, `better-sqlite3`, `*.db` | +| Hardcoded secrets | `password = "`, `apiKey: "sk-` | +| Hardcoded localhost | `localhost:`, `127.0.0.1:` (outside .env) | +| Memory sessions | `MemoryStore`, `express-session` without store | +| Cron without lock | `node-cron`, `setInterval` > 60s | +| WebSocket no adapter | `socket.io` without `@socket.io/redis-adapter` | +| File logging | `winston.*File`, `createWriteStream.*log` | +| No SIGTERM | absence of `SIGTERM` in codebase | +| No health check | absence of `/health` or `/healthz` route | diff --git a/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/criteria.md b/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/criteria.md new file mode 100644 index 00000000..87355c89 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/criteria.md @@ -0,0 +1,160 @@ +# Cloud-Native Readiness Scoring Criteria + +## Dimension 1: Statelessness (0-2) + +### Score 2 — Fully Stateless +- All persistent data stored in external database (PostgreSQL, MySQL, MongoDB) +- Session management via external store (Redis, DB) or stateless tokens (JWT) +- File uploads go to cloud storage (S3, R2, GCS) not local filesystem +- No in-memory caches that can't be lost (or cache is external like Redis) +- Application can be killed and restarted with zero data loss + +### Score 1 — Mostly Stateless +- Core data is external, but some local state exists: + - Temporary file processing (acceptable if using `/tmp`) + - In-memory cache for performance (acceptable if cache miss just hits DB) + - Local uploads that get moved to cloud storage eventually +- Losing an instance causes minor degradation, not data loss + +### Score 0 — Stateful +- SQLite or embedded database as primary store +- User uploads saved to local filesystem permanently +- In-memory session store (`MemoryStore`) +- Application state lives in process memory +- Killing instance = data loss + +--- + +## Dimension 2: Config Externalization (0-2) + +### Score 2 — Fully Externalized +- All environment-specific values from env vars +- `.env.example` documents all required variables +- Config validation at startup (e.g., `envalid`, `@t3-oss/env-nextjs`) +- No secrets in source code +- Same image works in dev/staging/prod with different env vars + +### Score 1 — Partially Externalized +- Most config via env vars, some hardcoded defaults +- `.env.example` exists but may be incomplete +- Some config files that could be overridden but aren't env-driven +- No secrets committed, but config isn't fully documented + +### Score 0 — Hardcoded +- Connection strings hardcoded in source +- Secrets committed to repo +- Config files with environment-specific values checked in +- No env var pattern + +--- + +## Dimension 3: Horizontal Scalability (0-2) + +### Score 2 — Fully Scalable +- Stateless HTTP handlers (REST/GraphQL) +- Background jobs via external queue (BullMQ, RabbitMQ, SQS) +- Database handles concurrency (proper transactions, no file locks) +- No singleton patterns that break with N instances +- WebSocket with Redis adapter (if applicable) + +### Score 1 — Mostly Scalable +- Core request handling is stateless +- Some single-instance concerns: + - Cron jobs without distributed lock + - WebSocket without sticky session support + - In-memory rate limiting +- Running 2+ instances mostly works, with minor issues + +### Score 0 — Single Instance Only +- File-based locking +- In-process scheduler with side effects +- Shared mutable state across requests +- Can only run one instance + +--- + +## Dimension 4: Startup/Shutdown (0-2) + +### Score 2 — Production Ready +- Explicit SIGTERM/SIGINT handling +- Graceful connection draining +- Health check endpoint (`/health`, `/healthz`, `/readyz`) +- Fast startup (< 10 seconds) +- Proper cleanup of resources on shutdown + +### Score 1 — Framework Defaults +- Framework handles basic lifecycle (Express, Next.js, Hono) +- No explicit signal handling but doesn't crash on SIGTERM +- No dedicated health endpoint but root responds quickly +- Moderate startup time (10-30 seconds) + +### Score 0 — Unmanaged +- No signal handling +- Long startup (> 30 seconds, loading large models/data) +- Abrupt termination loses in-flight requests +- No way to check if service is ready + +--- + +## Dimension 5: Observability (0-2) + +### Score 2 — Well Instrumented +- Structured logging (JSON to stdout/stderr) +- Error tracking (Sentry, Bugsnag) +- Metrics endpoint (Prometheus, custom) +- Request tracing (correlation IDs, OpenTelemetry) +- Centralized log-friendly output + +### Score 1 — Basic Logging +- Console.log to stdout (works with container log drivers) +- Some error handling middleware +- No structured format but parseable +- No metrics or tracing + +### Score 0 — Blind +- No logging or logs to local files only +- Silent error swallowing +- No way to diagnose issues in production +- No error reporting + +--- + +## Dimension 6: Service Boundaries (0-2) + +### Score 2 — Well Bounded +- Clear separation: each service has own entry point and package.json +- Independent deployment possible +- Well-defined API contracts (REST routes, GraphQL schema) +- Monorepo with apps/ directory pattern +- Database per service or clearly scoped queries + +### Score 1 — Logical Separation +- Routes/modules are organized but deploy as one unit +- Shared database with clear ownership +- Could be split into services with moderate effort +- Has clear API layer even if monolithic + +### Score 0 — Tightly Coupled +- Everything in one file or deeply intertwined +- No clear API boundaries +- Frontend and backend inseparable +- Circular dependencies between modules + +--- + +## Technology-Specific Bonuses (Informational, not scored) + +These don't affect the score but are noted in the report: + +### Naturally Cloud-Native Frameworks +- **Hono** — Edge-first, stateless by design +- **Fastify** — Fast startup, graceful shutdown built-in +- **Next.js** — Standalone output mode = container-ready +- **Go net/http** — Single binary, fast startup, graceful shutdown +- **FastAPI** — ASGI, stateless, Uvicorn handles signals + +### Requires Extra Attention +- **Express** — No built-in graceful shutdown (needs manual SIGTERM) +- **Django** — ORM connection management in containers +- **Spring Boot** — JVM startup time, memory tuning needed +- **Rails** — Asset pipeline, Puma worker configuration diff --git a/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/scoring-model.md b/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/scoring-model.md new file mode 100644 index 00000000..0c4347d2 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/scoring-model.md @@ -0,0 +1,177 @@ +# Deterministic Scoring Model + +This document describes the code-level scoring algorithm implemented in +`dockerfile-service/scripts/score-model.js`. It provides instant (< 1 second) +readiness scoring by analyzing the local filesystem of a cloned repo. + +## Architecture + +The model has two layers: +1. **Signal Detection** — filesystem scanning for files, dependencies, patterns +2. **Scoring Algorithm** — maps signals to 6 dimension scores (0-2 each) + +## Signal Detection + +### Language Detection +Scans root AND up to 2 levels deep (monorepo support): + +| File | Language | +|------|----------| +| `package.json` | Node.js (TypeScript/JavaScript) | +| `go.mod` | Go | +| `requirements.txt`, `pyproject.toml` | Python | +| `pom.xml`, `build.gradle` | Java | +| `Cargo.toml` | Rust | +| `composer.json` | PHP | +| `Gemfile` | Ruby | +| `*.csproj`, `*.sln` | .NET/C# | + +### Framework Detection (Node.js — scans ALL package.json in monorepo) +Collects dependencies from every `package.json` found up to 3 levels deep: + +| Dependency | Framework | +|-----------|-----------| +| `next` | Next.js | +| `hono` | Hono | +| `express` | Express | +| `fastify` | Fastify | +| `@nestjs/core` | NestJS | +| `nuxt` | Nuxt | +| `astro` | Astro | + +### HTTP Server Detection +The most critical signal — does this project listen on a port? + +| Condition | HTTP Detected | +|-----------|--------------| +| Node.js + any web framework | Yes | +| Node.js + `start` script in package.json | Yes | +| Go (always web) | Yes | +| Python + FastAPI/Django/Flask | Yes | +| Java + Spring Boot | Yes | +| Rust + actix-web/axum/rocket | Yes | +| PHP (always served via web server) | Yes | +| Ruby + rails/sinatra/puma | Yes | + +### State Externalization +Scans dependencies for database/cache libraries: + +| Signal | Libraries | +|--------|-----------| +| PostgreSQL | `pg`, `@prisma/client`, `drizzle-orm`, `typeorm`, `sequelize`, `psycopg`, `pgx` | +| MySQL | `mysql2`, `mysql`, `pymysql`, `go-sql-driver/mysql` | +| MongoDB | `mongoose`, `mongodb`, `pymongo`, `mongo-driver` | +| Redis | `redis`, `ioredis`, `@upstash/redis`, `go-redis` | +| SQLite | `better-sqlite3`, `sqlite3` (penalty: reduces statelessness score) | +| S3 | `@aws-sdk/client-s3`, `minio` | + +### Config Externalization +| Signal | Score Impact | +|--------|-------------| +| `.env.example` found (root or sub-dir) | +2 config | +| `.env` found but no `.env.example` | +1 config | +| `docker-compose` found | +1 config (implies env vars) | +| `@t3-oss/env-nextjs` or `envalid` | +2 config | + +### Docker Artifacts (Bonus points, not dimension) +| Signal | Bonus | +|--------|-------| +| `Dockerfile` exists | +1 | +| `docker-compose.yml` exists | +1 | + +## Scoring Algorithm + +### Dimension Scores (0-2 each, max raw = 12) + +``` +statelessness: + 2 = external DB (postgres/mysql/mongo) without sqlite + 1 = external DB + sqlite (mixed), or redis/s3 only, or web service without detected DB + 0 = no external state or HTTP + +config: + 2 = .env.example found OR env validation library + 1 = .env found or docker-compose exists + 0 = nothing detected + +scalability: + 2 = Go/Rust (compiled binary) OR HTTP + Redis + 1 = any HTTP handler + 0 = no HTTP + +startup: + 2 = Go/Rust OR Hono/Fastify (lightweight frameworks) + 1 = Next.js/Express/FastAPI/Django/Flask/Spring or has start script + 0 = nothing + +observability: + 2 = Dockerfile has HEALTHCHECK + 1 = HTTP handler (produces request logs) + 0 = nothing + +boundaries: + 2 = monorepo with apps/ dir, OR monorepo detected + 1 = single service with build pipeline or HTTP handler + 0 = nothing +``` + +### Bonus (capped at total 12) +- +1 if Dockerfile exists +- +1 if docker-compose exists + +### Final Score +``` +total = min(12, sum(dimensions) + bonus) + +Excellent (10-12): Fully cloud-native ready +Good (7-9): Ready with minor adjustments +Fair (4-6): Needs some refactoring +Poor (0-3): Significant rework needed +``` + +## Accuracy (measured against 164 Sealos production templates) + +All 164 templates are confirmed containerizable (ground truth = positive). + +| Threshold | Accuracy | +|-----------|----------| +| Score >= 4 (Fair+) | ~95% (target: catch almost everything) | +| Score >= 7 (Good+) | ~75% (target: confident recommendation) | + +Projects scoring below 4 are typically: +- Shell wrapper projects (language=Dockerfile or Shell) +- Unknown language repos (private or incomplete data) +- Clojure/Erlang (niche languages not in detection list) + +These edge cases are handled by the AI deep assessment fallback. + +## Usage + +### CLI +```bash +node scripts/score-model.js /path/to/repo +``` + +### Programmatic +```javascript +import { scoreProject } from './scripts/score-model.js'; + +const result = scoreProject('/path/to/cloned/repo'); +// result.score: 0-12 +// result.verdict: "Excellent" | "Good" | "Fair" | "Poor" +// result.dimensions: { statelessness, config, scalability, startup, observability, boundaries } +// result.signals: { language, framework, has_http_server, external_db, ... } +``` + +### API +```bash +# Fast (code-only, < 5 seconds including git clone) +curl -X POST http://localhost:3000/assess \ + -H 'Content-Type: application/json' \ + -d '{"github_url": "https://github.com/lobehub/lobe-chat"}' + +# Deep (AI-powered, 1-3 minutes, full markdown report) +curl -X POST http://localhost:3000/assess/deep \ + -H 'Content-Type: application/json' \ + -d '{"github_url": "https://github.com/lobehub/lobe-chat"}' +``` diff --git a/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/sealos-patterns.md b/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/sealos-patterns.md new file mode 100644 index 00000000..55aa01c6 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/cloud-native-readiness/knowledge/sealos-patterns.md @@ -0,0 +1,167 @@ +# Real-World Containerizable Project Patterns + +Data derived from analysis of 164 Sealos Cloud templates — all production-deployed containerized applications. + +## Key Finding + +**ALL 164 projects in the Sealos template marketplace are successfully containerized and running in production.** +This dataset provides ground truth for what "containerizable" looks like in practice. + +## Language Distribution (150 analyzed) + +| Language | Count | % | Dockerfile in Repo | +|----------|-------|---|-------------------| +| TypeScript | 53 | 35% | 47% have Dockerfile | +| Go | 23 | 15% | 61% have Dockerfile | +| Python | 18 | 12% | 67% have Dockerfile | +| Shell | 9 | 6% | 44% (wrapper projects) | +| JavaScript | 7 | 5% | 43% | +| PHP | 7 | 5% | 14% (use official images) | +| Java | 5 | 3% | 40% | +| Rust | 4 | 3% | 100% | +| Vue | 3 | 2% | 67% | +| C#/.NET | 2 | 1% | 0% (use pre-built images) | +| Others | 19 | 13% | varies | + +**Insight**: TypeScript + Go + Python + Rust = 65% of all containerizable projects. +Go and Rust have the highest Dockerfile presence (single binary advantage). + +## Docker Artifact Presence + +- **50% of repos have a Dockerfile** in the repository root +- **35% have docker-compose.yml** alongside +- **25% have both** Dockerfile + docker-compose +- **41% have neither** — Sealos builds from pre-built images or generates config + +**Insight**: Having NO Dockerfile doesn't mean "not containerizable". Many mature projects +publish pre-built images to registries (ghcr.io, Docker Hub), and Sealos references those directly. + +## Project Categories + +| Category | Count | Most Common Languages | +|----------|-------|-----------------------| +| tool | 91 | TypeScript, Go, PHP | +| ai | 34 | TypeScript, Python | +| backend | 16 | Go, TypeScript, Java | +| low-code | 13 | TypeScript | +| database | 13 | TypeScript, Go, Java | +| dev-ops | 8 | Go, Shell | +| game | 7 | Shell, Java | +| monitor | 6 | TypeScript, Go | +| blog | 4 | Java, TypeScript | +| storage | 3 | Go, Rust | + +## Common Dockerfile Patterns (from 30 deep-analyzed repos) + +### Multi-Stage Builds +- **88% use multi-stage builds** (2-5 stages) +- Average: 2.5 stages +- Pattern: `deps → build → runtime` +- Go/Rust projects: `build → scratch/alpine` (minimal final image) +- Node.js projects: `deps → build → node:slim` or `→ nginx` + +### Base Image Choices +| Runtime | Base Image | Used By | +|---------|-----------|---------| +| Node.js | `node:20-alpine`, `node:22-slim` | TypeScript/JavaScript apps | +| Go | `alpine:latest`, `scratch` | Go binaries | +| Python | `python:3.x-slim` | Python apps | +| Java | `eclipse-temurin:21-jre` | Spring Boot apps | +| Rust | `debian:slim`, `alpine` | Rust binaries | +| Static | `nginx:stable-alpine` | Vue/React SPAs | + +### Security Practices +- **35% use non-root USER** (e.g., `USER node`, `USER nextjs`, `USER 1000`) +- **18% have HEALTHCHECK** instruction +- Most use fixed image versions (not `:latest`) + +### Entry Point Patterns +| Pattern | Example | Used By | +|---------|---------|---------| +| Direct binary | `CMD ["./app"]` | Go, Rust | +| Node start | `CMD ["npm", "start"]` or `CMD ["pnpm", "start"]` | Node.js | +| Entrypoint script | `ENTRYPOINT ["./docker-entrypoint.sh"]` | Complex apps (migrations + start) | +| Custom server | `CMD ["node", "server.js"]` | Next.js standalone | +| Nginx | `CMD ["nginx", "-g", "daemon off;"]` | Static SPAs | + +## What Makes ALL These Projects Containerizable + +### Universal Characteristics (found in 100% of templates) + +1. **Web Service**: Every project exposes HTTP/HTTPS (API, dashboard, or web UI) +2. **External State**: Data stored in PostgreSQL, MySQL, MongoDB, Redis — never embedded-only +3. **Config via Environment**: All use env vars for connection strings, API keys, secrets +4. **Clear Entry Point**: Single binary, `npm start`, or well-defined startup command +5. **Single Responsibility**: Each container runs one process/service + +### Common External Dependencies + +| Dependency | Frequency | Typical Env Var | +|-----------|-----------|-----------------| +| PostgreSQL | Very High | `DATABASE_URL` | +| Redis | High | `REDIS_URL` | +| MySQL | Medium | `DATABASE_URL`, `MYSQL_*` | +| S3/MinIO | Medium | `S3_ENDPOINT`, `S3_ACCESS_KEY` | +| MongoDB | Medium | `MONGODB_URI` | +| OpenAI API | High (AI category) | `OPENAI_API_KEY` | + +### Monorepo Patterns (common in TypeScript projects) + +Many of the largest projects (Dify, AFFiNE, n8n, Plane, Twenty) are monorepos: +- Use Turborepo, pnpm workspaces, or nx +- Build specific app targets for Docker +- Often have separate Dockerfiles per service (api, web, worker) +- Use `--filter` or workspace commands in Dockerfile + +## Fast-Track Assessment Rules + +Based on this data, these characteristics almost guarantee containerization readiness: + +### Instant Pass (Score >= 10) +- Go or Rust single-binary web server +- Next.js/Nuxt app with `output: standalone` +- Python FastAPI/Flask with PostgreSQL +- Any project that already has Dockerfile + docker-compose + +### Likely Pass (Score >= 7) +- TypeScript monorepo with apps/ structure +- Java Spring Boot application +- PHP app with composer (use official PHP-FPM image) +- Any project using PostgreSQL/MySQL + Redis + +### Needs Investigation (Score 4-6) +- Projects with SQLite as primary DB (might need volume mount) +- Desktop/Electron apps with web component +- Projects with heavy local file processing + +### Likely Fail (Score 0-3) +- Pure CLI tools with no web server +- Desktop-only applications +- Projects requiring GPU without web API +- Embedded systems code + +## Sealos Template Structure Reference + +Each Sealos template defines: +```yaml +spec: + gitRepo: "https://github.com/org/repo" # Source code + defaults: + app_name: "xxx-${{ random(8) }}" # Random instance name + app_host: "xxx-${{ random(8) }}" # Random hostname + inputs: # User-configurable params + OPENAI_API_KEY: # Most common: API keys + type: string + required: true + admin_password: # Second: admin credentials + type: string +``` + +**Key patterns in inputs (most common)**: +1. `OPENAI_API_KEY` (9 templates) — AI service API key +2. `admin_password` (5) — Admin credentials +3. `api_key` (3) — Generic API key +4. `root_password` (3) — Database root password +5. `BASE_URL` (2) — Service URL configuration + +This tells us: containerizable apps externalize their secrets and API configurations. diff --git a/plugins/labring/sealos-skills/skills/cloud-native-readiness/modules/assess.md b/plugins/labring/sealos-skills/skills/cloud-native-readiness/modules/assess.md new file mode 100644 index 00000000..5edfa07a --- /dev/null +++ b/plugins/labring/sealos-skills/skills/cloud-native-readiness/modules/assess.md @@ -0,0 +1,405 @@ +# Module: Cloud-Native Readiness Assessment + +## Purpose + +Evaluate a project against 6 cloud-native dimensions to produce a readiness score (0-12). + +**Data source**: Patterns derived from 164 production-deployed Sealos Cloud templates. +See [knowledge/sealos-patterns.md](../knowledge/sealos-patterns.md) for the full dataset. + +## Pre-Assessment: Fast-Track Rules + +Before running the full 6-dimension assessment, check these fast-track rules derived +from 164 real-world containerized projects. If a fast-track matches, you can assign a +preliminary score and still verify with the full assessment. + +### Instant Pass (Preliminary Score >= 10) +Apply if ANY of these match: +- **Go/Rust single binary** with HTTP listener (e.g., `net/http`, `actix-web`, `axum`) +- **Next.js** app with `output: "standalone"` in next.config +- **Python FastAPI/Flask/Django** with external PostgreSQL/MySQL +- **Project already has Dockerfile + docker-compose** with health checks +- **Published to container registry** (ghcr.io, Docker Hub, ECR) + +### Likely Pass (Preliminary Score >= 7) +- TypeScript monorepo with `apps/` structure (Turborepo, pnpm workspaces, nx) +- Java Spring Boot application with external database +- PHP app with composer.json using official PHP-FPM base image pattern +- Any web service using PostgreSQL + Redis with env var config +- Python app with requirements.txt and Uvicorn/Gunicorn entry point + +### Needs Full Assessment +- Projects with SQLite as primary database +- Desktop/Electron apps that also have a web component +- Projects with heavy local file processing or GPU requirements +- CLI tools that may or may not expose HTTP + +### Likely Fail (Preliminary Score 0-3) +- Pure CLI tools with no HTTP server +- Desktop-only GUI applications (Electron without web API) +- Embedded systems or hardware-specific code +- Projects requiring persistent local state with no external DB + +## Execution Steps + +### Step 1: Identify Project Type + +First, determine the basic project characteristics: + +``` +Check for: +- package.json → Node.js ecosystem +- requirements.txt / pyproject.toml → Python +- go.mod → Go +- pom.xml / build.gradle → Java +- Cargo.toml → Rust +- composer.json → PHP +- Gemfile → Ruby +``` + +For monorepos, identify all services/apps: +``` +Check for: +- pnpm-workspace.yaml / turbo.json / nx.json → Monorepo +- apps/ or services/ directory → Multiple deployable units +- Each deployable unit should be assessed independently +``` + +**Output**: +```yaml +project: + name: "{from package.json or directory name}" + type: "monorepo | single-app" + language: "typescript | python | go | java | rust | php | ruby" + framework: "{detected framework}" + deployable_units: + - name: "api" + path: "apps/api" + type: "REST API" + - name: "cms" + path: "apps/cms" + type: "Web application" +``` + +### Step 2: Assess Statelessness (0-2 points) + +**What to check**: + +```bash +# Check for in-memory session stores +grep -rE "express-session|cookie-session|session\(\)|MemoryStore" --include="*.ts" --include="*.js" + +# Check for local file system writes (non-temp) +grep -rE "fs\.(write|append|mkdir)|writeFile|createWriteStream" --include="*.ts" --include="*.js" | grep -v "node_modules" | grep -v "/tmp" + +# Check for in-memory caches without external backing +grep -rE "new Map\(\)|global\.\w+Cache|let cache =|const cache =" --include="*.ts" --include="*.js" + +# Check for SQLite or local database files +grep -rE "sqlite|better-sqlite3|\.db\"|\.sqlite" --include="*.ts" --include="*.js" +find . -name "*.db" -o -name "*.sqlite" | head -5 + +# Check for local upload directories (non-cloud storage) +grep -rE "multer\.diskStorage|upload.*dest.*['\"]\./" --include="*.ts" --include="*.js" +``` + +**Scoring**: +- **2**: Fully stateless. State externalized to DB/Redis/S3. No local file dependency. +- **1**: Mostly stateless. Minor local state (temp files, build cache) but core state is external. +- **0**: Stateful. In-memory sessions, local file storage for user data, SQLite. + +**Positive indicators** (state externalized): +- Uses PostgreSQL/MySQL/MongoDB for data → external DB +- Uses Redis/Memcached for sessions/cache → external cache +- Uses S3/R2/GCS for file storage → external storage +- Uses JWT or external auth (Better Auth, NextAuth) → stateless auth + +**Negative indicators** (local state): +- `MemoryStore` for sessions +- `fs.writeFileSync` for user uploads +- SQLite as primary database +- In-process cron jobs with state + +### Step 3: Assess Config Externalization (0-2 points) + +**What to check**: + +```bash +# Check for environment variable usage +grep -rE "process\.env\.|os\.environ|os\.Getenv|System\.getenv" --include="*.ts" --include="*.js" --include="*.py" --include="*.go" | wc -l + +# Check for .env file patterns +ls -la .env* 2>/dev/null +ls -la */.env* 2>/dev/null + +# Check for hardcoded connection strings +grep -rE "(localhost|127\.0\.0\.1):\d{4}" --include="*.ts" --include="*.js" | grep -v "node_modules" | grep -v ".env" + +# Check for hardcoded secrets +grep -rE "password\s*[:=]\s*['\"][^'\"]+['\"]|secret\s*[:=]\s*['\"][^'\"]+['\"]" --include="*.ts" --include="*.js" | grep -v "node_modules" | grep -v ".env" | grep -v "placeholder" + +# Check for config/env validation (good practice) +grep -rE "createEnv|envalid|env-var|joi.*env|zod.*env" --include="*.ts" --include="*.js" +``` + +**Scoring**: +- **2**: All config via env vars. `.env.example` exists. No hardcoded secrets. Config validation present. +- **1**: Mostly env var driven. Some hardcoded defaults but overridable. `.env.example` may be incomplete. +- **0**: Hardcoded configs, connection strings, or secrets in source code. No env var pattern. + +**Positive indicators**: +- `.env.example` with documented variables +- `@t3-oss/env-nextjs` or `envalid` for validation +- All connection strings from env vars +- Docker-friendly config patterns (12-factor) + +**Negative indicators**: +- Hardcoded `localhost:5432` without env var fallback +- Secrets committed in config files +- Config files that can't be overridden at runtime + +### Step 4: Assess Horizontal Scalability (0-2 points) + +**What to check**: + +```bash +# Check for WebSocket with sticky sessions concern +grep -rE "WebSocket|socket\.io|ws\(" --include="*.ts" --include="*.js" + +# Check for distributed-friendly patterns +grep -rE "Redis|BullMQ|bull|@upstash|amqp|kafka" --include="*.ts" --include="*.js" + +# Check for file-based locks +grep -rE "lockfile|\.lock\"|flock|advisory.*lock" --include="*.ts" --include="*.js" + +# Check for singleton patterns that break with multiple instances +grep -rE "global\.\w+\s*=|globalThis\.\w+\s*=" --include="*.ts" --include="*.js" | grep -v "prisma" + +# Check for cron/scheduler (single-instance concern) +grep -rE "node-cron|cron\.schedule|setInterval.*\d{4,}" --include="*.ts" --include="*.js" + +# Check for leader election or distributed lock patterns (good sign) +grep -rE "redlock|@upstash/lock|leader.*election" --include="*.ts" --include="*.js" +``` + +**Scoring**: +- **2**: Fully horizontally scalable. Stateless requests, external queue for background jobs, no file locks. +- **1**: Mostly scalable. May need sticky sessions for WebSocket, or has cron jobs that should be single-instance. +- **0**: Single-instance only. File-based locks, in-process schedulers with side effects, shared mutable state. + +**Positive indicators**: +- REST/GraphQL API (naturally stateless) +- Redis-backed queues (BullMQ, etc.) +- Database-level locking (not file-level) +- No in-process cron with side effects + +**Negative indicators**: +- `setInterval` for scheduled tasks without distributed lock +- File-based locking mechanisms +- In-memory pub/sub without Redis adapter + +### Step 5: Assess Startup/Shutdown (0-2 points) + +**What to check**: + +```bash +# Check for graceful shutdown handling +grep -rE "SIGTERM|SIGINT|process\.on.*signal|graceful.*shutdown|beforeExit" --include="*.ts" --include="*.js" + +# Check for health check endpoints +grep -rE "health|healthz|readyz|livez|ready|alive" --include="*.ts" --include="*.js" --include="*.py" + +# Check for long initialization (e.g., loading large ML models) +grep -rE "loadModel|warmup|preload|initialize.*cache" --include="*.ts" --include="*.js" + +# Check framework - some handle graceful shutdown automatically +grep -rE "hono|express|fastify|nestjs|next" package.json 2>/dev/null + +# Check for connection draining +grep -rE "server\.close|drain|closeAllConnections" --include="*.ts" --include="*.js" +``` + +**Scoring**: +- **2**: Handles SIGTERM gracefully. Has health check endpoints. Fast startup (< 10s). +- **1**: Framework handles basic shutdown. No explicit health check but responds to HTTP quickly. Moderate startup. +- **0**: No signal handling. Long startup (loads large resources). Abrupt termination risks. + +**Positive indicators**: +- Explicit `SIGTERM` handler +- `/health` or `/healthz` endpoint +- Frameworks like Hono/Fastify (lightweight, fast startup) +- Connection pooling with proper cleanup + +**Negative indicators**: +- Loading large files at startup without lazy loading +- No graceful shutdown in custom server +- Long database migration at startup + +### Step 6: Assess Observability (0-2 points) + +**What to check**: + +```bash +# Check for structured logging +grep -rE "pino|winston|bunyan|structured.*log|JSON\.stringify.*log" --include="*.ts" --include="*.js" + +# Check for console.log (not ideal but functional) +grep -rE "console\.(log|error|warn)" --include="*.ts" --include="*.js" | wc -l + +# Check for metrics/monitoring +grep -rE "prometheus|prom-client|datadog|newrelic|opentelemetry|@sentry" --include="*.ts" --include="*.js" + +# Check for request tracing +grep -rE "trace-id|x-request-id|correlation-id|opentelemetry" --include="*.ts" --include="*.js" + +# Check for error tracking +grep -rE "sentry|bugsnag|rollbar|errorHandler" --include="*.ts" --include="*.js" +``` + +**Scoring**: +- **2**: Structured logging (JSON). Metrics endpoint. Error tracking. Request tracing. +- **1**: Has logging (even console.log to stdout). Some error handling. No metrics. +- **0**: No logging. Silent failures. No observability infrastructure. + +**Positive indicators**: +- Structured JSON logging → works with log aggregators +- Sentry/error tracking → crash reporting +- Prometheus metrics → monitoring +- Logs to stdout/stderr → container-friendly + +**Negative indicators**: +- Logging to local files only (not stdout) +- No error handling middleware +- Silent `catch {}` blocks + +### Step 7: Assess Service Boundaries (0-2 points) + +**What to check**: + +```bash +# Check if it's a monorepo with clear service separation +ls apps/ services/ 2>/dev/null + +# Check for clear API boundaries +grep -rE "app\.(get|post|put|delete|use)" --include="*.ts" --include="*.js" | head -5 +grep -rE "router\.(get|post|put|delete)" --include="*.ts" --include="*.js" | head -5 + +# Check for tightly coupled components +# (e.g., frontend and backend in same process) +grep -rE "next.*custom.*server|express.*next\(" --include="*.ts" --include="*.js" + +# Check for shared database access pattern +grep -rE "prisma|drizzle|typeorm|sequelize" --include="*.ts" --include="*.js" | + cut -d: -f1 | sort -u + +# For monorepos: check if services can deploy independently +ls apps/*/package.json 2>/dev/null +``` + +**Scoring**: +- **2**: Clear service boundaries. Each service has its own entry point, dependencies, and can deploy independently. +- **1**: Logical separation exists (routes, modules) but deployed as single unit. Monorepo with shared DB is fine. +- **0**: Tightly coupled monolith. No clear service boundaries. Everything in one process with cross-cutting concerns. + +**Positive indicators**: +- Monorepo with `apps/` directory and independent package.json per app +- API and frontend are separate deployable units +- Clear route/controller structure +- REST/GraphQL API with well-defined endpoints + +**Negative indicators**: +- Single `index.js` with everything +- Frontend rendering and API in same server without separation +- Circular dependencies between modules + +### Step 8: Calculate Total Score and Produce Report + +Sum all dimension scores (0-12) and determine rating: + +``` +12-10: ★★★★★ Excellent — Fully cloud-native ready + 9-7: ★★★★ Good — Ready with minor adjustments + 6-4: ★★★ Fair — Needs some refactoring + 3-0: ★★ Poor — Significant rework needed +``` + +**For monorepos**: Assess each deployable unit separately, then provide an overall score. + +### Output Format + +```yaml +assessment: + project_name: "{name}" + project_type: "monorepo | single-app" + overall_score: {0-12} + rating: "Excellent | Good | Fair | Poor" + verdict: "Ready | Ready with caveats | Needs work | Not recommended" + + dimensions: + statelessness: + score: {0-2} + findings: + - "{specific finding}" + evidence: + positive: ["{what's good}"] + negative: ["{what's concerning}"] + + config_externalization: + score: {0-2} + findings: + - "{specific finding}" + evidence: + positive: [] + negative: [] + + horizontal_scalability: + score: {0-2} + findings: [] + evidence: + positive: [] + negative: [] + + startup_shutdown: + score: {0-2} + findings: [] + evidence: + positive: [] + negative: [] + + observability: + score: {0-2} + findings: [] + evidence: + positive: [] + negative: [] + + service_boundaries: + score: {0-2} + findings: [] + evidence: + positive: [] + negative: [] + + # Per-unit assessment for monorepos + units: + - name: "api" + path: "apps/api" + score: {0-12} + notes: "{specific notes}" + - name: "cms" + path: "apps/cms" + score: {0-12} + notes: "{specific notes}" + + strengths: + - "{summary of what's already good}" + + concerns: + - "{issues that need attention}" + + blockers: + - "{critical issues, if any}" + + recommendations: + - "{actionable next steps}" +``` diff --git a/plugins/labring/sealos-skills/skills/cloud-native-readiness/modules/detect.md b/plugins/labring/sealos-skills/skills/cloud-native-readiness/modules/detect.md new file mode 100644 index 00000000..2cd2bcf0 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/cloud-native-readiness/modules/detect.md @@ -0,0 +1,209 @@ +# Module: Existing Docker Artifacts Detection + +## Purpose + +Detect whether the project already has Docker/K8s configuration and assess its completeness. + +## Execution Steps + +### Step 1: Scan for Docker Files + +```bash +# Dockerfile variants +find . -maxdepth 3 -name "Dockerfile" -o -name "Dockerfile.*" -o -name "*.Dockerfile" 2>/dev/null | grep -v node_modules + +# Docker Compose variants +find . -maxdepth 3 \( -name "docker-compose.yml" -o -name "docker-compose.yaml" -o -name "compose.yml" -o -name "compose.yaml" -o -name "docker-compose.*.yml" \) 2>/dev/null | grep -v node_modules + +# .dockerignore +find . -maxdepth 3 -name ".dockerignore" 2>/dev/null | grep -v node_modules + +# Docker documentation +find . -maxdepth 3 -name "DOCKER.md" -o -name "docker-README.md" 2>/dev/null | grep -v node_modules + +# Docker-related env files +find . -maxdepth 3 -name ".env.docker*" -o -name "*.dev.vars*" 2>/dev/null | grep -v node_modules + +# Entrypoint scripts +find . -maxdepth 3 -name "docker-entrypoint.sh" -o -name "entrypoint.sh" 2>/dev/null | grep -v node_modules +``` + +### Step 2: Scan for Kubernetes / Deployment Manifests + +```bash +# Kubernetes manifests +find . -maxdepth 4 -type d \( -name "k8s" -o -name "kubernetes" -o -name "kube" -o -name "manifests" \) 2>/dev/null | grep -v node_modules + +# Helm charts +find . -maxdepth 4 -type d -name "charts" 2>/dev/null | grep -v node_modules +find . -maxdepth 4 -name "Chart.yaml" 2>/dev/null | grep -v node_modules + +# Kustomize +find . -maxdepth 4 -name "kustomization.yaml" -o -name "kustomization.yml" 2>/dev/null | grep -v node_modules + +# Skaffold +find . -maxdepth 2 -name "skaffold.yaml" 2>/dev/null + +# Tilt +find . -maxdepth 2 -name "Tiltfile" 2>/dev/null + +# Docker Swarm +grep -rl "deploy:" docker-compose*.yml compose*.yml 2>/dev/null | head -5 +``` + +### Step 3: Scan for CI/CD Docker Build Steps + +```bash +# GitHub Actions +grep -rl "docker" .github/workflows/*.yml .github/workflows/*.yaml 2>/dev/null +grep -rE "docker.*build|docker.*push|ghcr\.io|docker\.io" .github/workflows/ 2>/dev/null | head -10 + +# GitLab CI +grep -E "docker|image:|registry" .gitlab-ci.yml 2>/dev/null | head -10 + +# Other CI +find . -maxdepth 2 \( -name "Jenkinsfile" -o -name ".circleci" -o -name "bitbucket-pipelines.yml" \) 2>/dev/null +``` + +### Step 4: Detect Container Registry References + +```bash +# Search for registry references in all config files +grep -rE "(ghcr\.io|docker\.io|registry\.hub|ecr\.aws|gcr\.io|azurecr\.io|quay\.io)/[a-z0-9._/-]+" . \ + --include="*.yml" --include="*.yaml" --include="*.json" --include="*.toml" --include="*.md" \ + 2>/dev/null | grep -v node_modules | head -10 + +# Check package.json for docker-related scripts +grep -E '"docker|"container|"image' package.json 2>/dev/null +``` + +### Step 5: Assess Quality of Existing Artifacts + +If Dockerfile found, check for: + +```bash +# Multi-stage build? +grep -c "^FROM" Dockerfile + +# Non-root user? +grep -E "USER|useradd|adduser" Dockerfile + +# Health check? +grep "HEALTHCHECK" Dockerfile + +# Proper .dockerignore? +if [ -f ".dockerignore" ]; then + wc -l .dockerignore + grep -E "node_modules|\.git|\.env" .dockerignore +fi + +# Fixed base image version (not :latest)? +grep "^FROM" Dockerfile | grep -v ":latest" + +# Uses COPY before RUN for cache optimization? +grep -n "^COPY\|^RUN" Dockerfile | head -20 +``` + +If docker-compose found, check for: + +```bash +# Health checks defined? +grep -c "healthcheck" docker-compose.yml + +# Volumes for persistent data? +grep -c "volumes:" docker-compose.yml + +# Networks defined? +grep -c "networks:" docker-compose.yml + +# Environment variables properly handled? +grep -cE "env_file|\$\{" docker-compose.yml + +# Restart policy? +grep -E "restart:" docker-compose.yml +``` + +### Step 6: Produce Artifact Inventory + +**Output Format**: + +```yaml +artifacts: + status: "complete | partial | none" + + dockerfile: + found: true | false + paths: ["Dockerfile", "apps/api/Dockerfile"] + quality: + multi_stage: true | false + non_root_user: true | false + health_check: true | false + fixed_versions: true | false + cache_optimized: true | false + score: "{good | acceptable | poor}" + + docker_compose: + found: true | false + paths: ["docker-compose.yml"] + quality: + health_checks: true | false + volumes: true | false + networks: true | false + env_handling: true | false + restart_policy: true | false + score: "{good | acceptable | poor}" + + dockerignore: + found: true | false + paths: [".dockerignore"] + covers_essentials: true | false # node_modules, .git, .env + + kubernetes: + found: true | false + type: "raw manifests | helm | kustomize | none" + paths: [] + + ci_cd: + docker_build: true | false + registry_push: true | false + platforms: ["github-actions", "gitlab-ci"] + + registry: + found: true | false + references: ["ghcr.io/org/repo"] + + entrypoint: + found: true | false + paths: [] + + documentation: + found: true | false + paths: [] + + # Overall completeness + completeness: + has_build: true | false # Can build an image + has_orchestration: true | false # Can run with dependencies + has_deployment: true | false # Can deploy to K8s/cloud + has_ci: true | false # Automated build pipeline + summary: "Production-ready | Development-ready | Incomplete | None" +``` + +### Decision Points + +Based on artifact inventory: + +**Complete** (`status: "complete"`): +- Has Dockerfile with acceptable+ quality +- Has docker-compose with all dependent services +- Has .dockerignore +→ Report findings, no need for `dockerfile-skill` + +**Partial** (`status: "partial"`): +- Has some artifacts but missing key pieces +- Or has artifacts with poor quality +→ Report gaps, suggest improvements or invoke `dockerfile-skill` + +**None** (`status: "none"`): +- No Docker artifacts found +→ Proceed to `dockerfile-skill` if readiness score permits diff --git a/plugins/labring/sealos-skills/skills/cloud-native-readiness/modules/route.md b/plugins/labring/sealos-skills/skills/cloud-native-readiness/modules/route.md new file mode 100644 index 00000000..4778596a --- /dev/null +++ b/plugins/labring/sealos-skills/skills/cloud-native-readiness/modules/route.md @@ -0,0 +1,147 @@ +# Module: Decision Routing + +## Purpose + +Based on the assessment score and artifact detection results, determine the next action. + +## Decision Matrix + +``` +┌─────────────────┬──────────────────┬─────────────────────────────────────┐ +│ Readiness Score │ Artifacts Status │ Action │ +├─────────────────┼──────────────────┼─────────────────────────────────────┤ +│ ≥ 7 (Good+) │ Complete │ REPORT: Return existing setup info │ +│ ≥ 7 (Good+) │ Partial │ REPORT: Show gaps + improvements │ +│ ≥ 7 (Good+) │ None │ HANDOFF: Invoke dockerfile-skill │ +│ 4-6 (Fair) │ Complete │ REPORT: Show artifacts + concerns │ +│ 4-6 (Fair) │ Partial/None │ ASK: Confirm with user, then optionally handoff │ +│ 0-3 (Poor) │ Any │ STOP: Report blockers, do NOT containerize │ +└─────────────────┴──────────────────┴─────────────────────────────────────┘ +``` + +## Execution Steps + +### Step 1: Evaluate Decision + +Read the assessment result and artifact inventory from previous modules. + +```yaml +input: + assessment_score: {0-12} + assessment_rating: "{Excellent | Good | Fair | Poor}" + artifacts_status: "{complete | partial | none}" +``` + +### Step 2: Route — REPORT (Artifacts Exist) + +When artifacts are found and readiness is Good+: + +1. **Summarize existing setup**: + - List all found Dockerfiles and their quality + - List docker-compose configuration + - List K8s manifests if any + - Note any CI/CD integration + +2. **Assess completeness**: + - Can the user `docker-compose up` right now? + - Are all dependent services covered? + - Is the Dockerfile production-quality? + +3. **Suggest improvements** (if partial): + - Missing health checks + - Missing .dockerignore + - Using :latest instead of fixed versions + - Missing multi-stage build + - No non-root user + - Missing restart policy in compose + +4. **Output the readiness report** (format from SKILL.md) + +### Step 3: Route — HANDOFF (Need to Generate) + +When score ≥ 7 and no artifacts exist: + +1. **Output the readiness report** first + +2. **Inform the user**: + ``` + This project is ready for containerization but has no Docker configuration yet. + Invoking dockerfile-skill to generate production-ready Docker setup... + ``` + +3. **Invoke dockerfile-skill** with context: + - Pass the detected language, framework, package manager + - Pass external service dependencies + - Pass any specific concerns from the assessment + - Use: `/dockerfile` on the current project path + +4. **The dockerfile-skill will handle**: + - Deep project analysis (its own Phase 1) + - Dockerfile generation (Phase 2) + - Build validation (Phase 3) + - Runtime validation (Phase 4) + +### Step 4: Route — ASK (Fair Score) + +When score is 4-6: + +1. **Output the readiness report** with concerns highlighted + +2. **Present options to user**: + - Option A: Proceed with containerization anyway (with caveats) + - Option B: Address the concerns first, then re-run assessment + - Option C: Containerize with documented limitations + +3. **If user chooses to proceed**: + - Add assessment concerns as comments in generated Dockerfile + - Include warnings in DOCKER.md + - Invoke `dockerfile-skill` + +4. **If user chooses to address concerns**: + - Provide specific, actionable remediation steps: + - Which files to modify + - What patterns to add (e.g., SIGTERM handler, health endpoint) + - What dependencies to externalize + +### Step 5: Route — STOP (Poor Score) + +When score is 0-3: + +1. **Output the readiness report** with blockers + +2. **Provide remediation roadmap**: + ```markdown + ## Remediation Steps (Priority Order) + + ### 1. [Highest impact blocker] + - What: {describe the issue} + - Why: {why it blocks containerization} + - How: {specific code changes needed} + - Effort: {low | medium | high} + + ### 2. [Next blocker] + ... + ``` + +3. **Do NOT invoke dockerfile-skill** + - Generating a Dockerfile for a project that isn't ready leads to: + - Broken containers + - Silent runtime failures + - False sense of deployment readiness + +4. **Offer to re-assess** after the user makes changes + +## Final Output + +Regardless of route taken, always end with a clear summary: + +```markdown +## Next Steps + +{One of:} +- ✅ Your project is already containerized. See the artifacts listed above. +- 🔧 Minor improvements suggested for your existing Docker setup (see above). +- 🐳 Generating Docker configuration now via dockerfile-skill... +- ⚠️ Some concerns noted. Would you like to proceed anyway or address them first? +- 🚫 Not recommended for containerization yet. See remediation steps above. +``` diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/SKILL.md b/plugins/labring/sealos-skills/skills/docker-to-sealos/SKILL.md new file mode 100644 index 00000000..aa6d0a8a --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/SKILL.md @@ -0,0 +1,275 @@ +--- +name: docker-to-sealos +description: Convert Docker Compose files or installation docs into production-grade Sealos templates. Use when user has a docker-compose.yml and wants a Sealos or Kubernetes template, wants to migrate from Docker Compose to Sealos, needs to convert container orchestration configs to Sealos format, or mentions compose-to-template conversion. Also triggers on "/docker-to-sealos". +--- + +# Docker to Sealos Template Converter + +## Overview + +Convert Docker Compose files or installation docs into production-grade Sealos templates. +Execute end-to-end automatically (analysis, conversion, validation, output) without asking users for missing fields. + +## Governance and Rule Priority + +Use the following precedence to prevent rule drift: + +1. `SKILL.md` MUST rules (this file) +2. `references/sealos-specs.md` and `references/database-templates.md` +3. `references/conversion-mappings.md` and `references/example-guide.md` + +If lower-priority references conflict with higher-priority MUST rules, update the lower-priority files. +Do not keep conflicting examples. + +## Workflow + +### Step 1: Analyze input + +Extract from Docker Compose/docs: + +- application services vs database services +- volumes/config mounts/object storage requirements +- ports, dependencies, service communication +- env vars and secret usage +- resource limits/requests and health checks +- if official Kubernetes installation docs/manifests are available, also extract app-runtime behavior from them (bootstrap admin fields, external endpoint/protocol assumptions, health probes, startup/init flow) + +### Step 2: Infer metadata + +Infer and normalize: + +- app name, title, description, categories +- official URL, gitRepo, icon source (prefer square/circular icon-first assets such as app icons, favicons, or avatars; avoid rectangular wordmark/text logos) +- locale/i18n metadata + +### Step 3: Plan resources in strict order + +Generate resources in this order: + +1. Template CR +2. ObjectStorageBucket (if needed) +3. Database resources (ServiceAccount → Role → RoleBinding → Cluster → Job if needed) +4. App workload resources (ConfigMap/Secret → Deployment/StatefulSet → Service → Ingress) +5. App resource (last) + +### Step 4: Apply conversion rules + +Apply field-level mappings from `references/conversion-mappings.md`, including: + +- image pinning and annotation mapping +- port/service/ingress conversion +- env var conversion and dependency ordering +- storage conversion and vn naming (`scripts/path_converter.py`) +- service-name to Kubernetes FQDN conversion +- for DB URL/DSN envs (for example `*_DATABASE_URL`, `*_DB_URL`), when Kubeblocks `endpoint` is host:port, inject `host`/`port`/`username`/`password` via approved `secretKeyRef` envs and compose the final URL with `$(VAR)` expansion +- edge gateway normalization: when Compose includes Traefik-like edge proxy plus business services, skip the proxy workload and expose business services via Sealos Ingress directly +- TLS offload normalization for Sealos Ingress: when a business service exposes both 80 and 443, drop 443 from workload/service ports and remove in-container TLS certificate mounts (for example `/etc/nginx/ssl`, `/etc/ssl`, `/certs`) unless official Kubernetes docs explicitly require HTTPS backend-to-service traffic +- prefer `scripts/compose_to_template.py --kompose-mode always` as deterministic conversion entrypoint (require `kompose` for reproducible workload shaping) +- when official Kubernetes installation docs/manifests exist, perform a dual-source merge: use Compose as baseline topology, then align app-runtime semantics with official Kubernetes guidance + +### Step 5: Apply database strategy + +- PostgreSQL must follow the pinned version and structure requirements. +- MySQL/MongoDB/Redis/Kafka must use templates and secret naming from `references/database-templates.md`. +- Add DB init Job/initContainer when application database bootstrap requires it. +- For PostgreSQL custom databases (non-`postgres`), the init Job must wait for PostgreSQL readiness before execution and create the target database idempotently. + +### Step 6: Generate output files + +Always produce: + +- `template//index.yaml` +- `template//logo.` when official icon is resolvable, prioritizing square/circular icon-first artwork and avoiding rectangular wordmark/text logos + +Never create: + +- `template//README.md` +- `template//README_zh.md` + +README authoring is out of scope for this skill. If the Template CR requires README URLs, populate URL fields in `index.yaml` only and leave file creation to a dedicated README skill. + +### Step 7: Validate before output + +Run validator and self-tests before delivering template output. +If validation fails, fix template/rules/examples first. + +## MUST Rules (Condensed) + +### Naming and metadata + +- Template `metadata.name` must be hardcoded lowercase; do not use `${{ defaults.app_name }}`. +- Template CR folder name must match `metadata.name`. +- Template CR must include required metadata fields (`title`, `url`, `gitRepo`, `author`, `description`, `icon`, `templateType`, `locale`, `i18n`, `categories`). +- Template `spec.readme` must point to `https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template//README.md`. +- Template `spec.i18n.zh.readme` must point to `https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template//README_zh.md`. +- These README fields are URL references in `index.yaml` only; this skill must not create or update the referenced README files. +- `icon` URL must point to template repo raw path for this app on `kb-0.9` branch. +- `template//logo.` must use square/circular icon-first artwork (for example app icon/favicon/avatar), and must not use rectangular wordmark/text logos. +- `i18n.zh.description` must be written in Simplified Chinese. +- Omit `i18n.zh.title` when it is identical to `title`. +- `categories` must only use predefined values (`tool`, `ai`, `game`, `database`, `low-code`, `monitor`, `dev-ops`, `blog`, `storage`, `frontend`, `backend`). + +### App resource + +- App resource must use `spec.data.url`. +- App resource `spec.displayType` must be `normal`. +- App resource `spec.type` must be `link`. +- Never use `spec.template` in App resource. +- `cloud.sealos.io/app-deploy-manager` label value must equal resource `metadata.name`. +- `metadata.labels.app` label value must equal resource `metadata.name` for managed app workloads. +- `containers[*].name` must equal workload `metadata.name` for managed app workloads. +- Application `Service` resources must define `metadata.labels.app` and `metadata.labels.cloud.sealos.io/app-deploy-manager`, and both labels must match `spec.selector.app`. +- Component-scoped `ConfigMap` resources must define `metadata.labels.app` and `metadata.labels.cloud.sealos.io/app-deploy-manager`, and both labels must match `metadata.name`. +- Application `Service` resources must use the same component name across `metadata.name`, `metadata.labels.app`, `metadata.labels.cloud.sealos.io/app-deploy-manager`, and `spec.selector.app`. +- Application `Ingress` resources must use the same component name across `metadata.name`, `metadata.labels.cloud.sealos.io/app-deploy-manager`, and backend `service.name`. +- Service `spec.ports[*].name` must be explicitly set (required for multi-port services). +- HTTP Ingress must include required nginx annotations (`kubernetes.io/ingress.class`, `nginx.ingress.kubernetes.io/proxy-body-size`, `nginx.ingress.kubernetes.io/server-snippet`, `nginx.ingress.kubernetes.io/ssl-redirect`, `nginx.ingress.kubernetes.io/backend-protocol`, `nginx.ingress.kubernetes.io/client-body-buffer-size`, `nginx.ingress.kubernetes.io/proxy-buffer-size`, `nginx.ingress.kubernetes.io/proxy-send-timeout`, `nginx.ingress.kubernetes.io/proxy-read-timeout`, `nginx.ingress.kubernetes.io/configuration-snippet`) with expected defaults. +- CronJob resources must define labels `cloud.sealos.io/cronjob`, `cronjob-launchpad-name`, and `cronjob-type`; `cloud.sealos.io/cronjob` must equal `metadata.name`, `cronjob-launchpad-name` must be `""`, and `cronjob-type` must be `image`. +- When official application health checks are available, managed workloads must define `livenessProbe`, `readinessProbe`, and (for slow bootstrap apps) `startupProbe`, aligned with official endpoints/commands. + +### Official Kubernetes alignment + +- If official Kubernetes installation docs/manifests are available, conversion must reference them and align critical runtime settings before emitting template artifacts. +- When official Kubernetes docs/manifests and Compose differ, prefer official Kubernetes runtime semantics for app behavior (bootstrap admin fields, external endpoint/env/protocol, health probes), unless doing so violates higher-priority Sealos MUST/security constraints. + +### Images and pull policy + +- Do not use `:latest`. +- Resolve versions with `crane`: prefer an explicit version tag (for example `v2.2.0`), and fallback to digest pin only when a deterministic version tag is unavailable. +- Avoid floating tags (for example `:v2`, `:2.1`, `:stable`); use an explicit version tag or digest. +- Managed workload image references must be concrete and must not contain Compose-style variable expressions (for example `${VAR}`, `${VAR:-default}`); resolve to explicit tag or digest before emitting template artifacts. +- Application `originImageName` must match container image. +- Managed app workloads must reference the app-scoped image pull Secret `${{ defaults.app_name }}` via `template.spec.imagePullSecrets`. +- The registry pull Secret is runtime-managed by `sealos-deploy` using local `gh` CLI credentials for private GHCR images; do not expose raw registry credential inputs in generated templates. +- All containers must explicitly set `imagePullPolicy: IfNotPresent`. + +### Storage + +- Do not use `emptyDir`. +- Use persistent storage patterns (`volumeClaimTemplates`) where storage is needed. +- PVC request must be `<= 1Gi` unless source spec explicitly requires less. +- ConfigMap keys and volume names must follow vn naming (`scripts/path_converter.py`). + +### Env and secrets + +- Non-database sensitive values/inputs use direct `env[].value`. +- Business containers must source database connection fields (`endpoint`, `host`, `port`, `username`, `password`) from approved Kubeblocks database secrets via `env[].valueFrom.secretKeyRef`; exception: Redis `host`/`port` may use Sealos Redis Service FQDN and `6379` when the Redis secret only exposes credentials, and MongoDB connection URLs may use the Sealos MongoDB Service FQDN plus `27017` when the MongoDB secret exposes credentials only. +- Business containers must not use custom env/volume `Secret` references except approved Kubeblocks database secrets and object storage secrets. +- A dedicated app-scoped registry pull Secret is allowed and should be referenced only through `template.spec.imagePullSecrets`. +- Database connection/bootstrap may use Kubeblocks-provided secrets, and reserved Kubeblocks database secret names must not be redefined by custom `Secret` resources. +- Env vars must be declared before referenced (for example password before URL composition). +- Follow official app env var naming; do not invent prefixes. +- When the application requires its public URL configured via a file-based config system (e.g., node-config `config/default.json`, PHP config files), create a ConfigMap containing the config file with the public URL set to `https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}`, and mount it to the application's config directory. The ConfigMap must follow standard naming and label conventions. +- For PostgreSQL custom databases (non-`postgres`), include `${{ defaults.app_name }}-pg-init` Job and implement startup-safe/idempotent creation logic (readiness wait + existence check before create). + +### Database-specific constraints + +- PostgreSQL version: `postgresql-16.4.0`. +- PostgreSQL API: `apps.kubeblocks.io/v1alpha1`. +- PostgreSQL RBAC unified naming: `${{ defaults.app_name }}-pg`. +- PostgreSQL RBAC requires `app.kubernetes.io/instance` and `app.kubernetes.io/managed-by` labels. +- PostgreSQL role wildcard permission requirement remains as defined in current spec. +- PostgreSQL cluster must include required labels/fields (`kb.io/database: postgresql-16.4.0`, `clusterdefinition.kubeblocks.io/name: postgresql`, `clusterversion.kubeblocks.io/name: postgresql-16.4.0`, `clusterVersionRef: postgresql-16.4.0`, `disableExporter: true`, `enabledLogs: [running]`, `switchPolicy.type: Noop`, `serviceAccountName`). +- MongoDB cluster must follow upgraded structure (`componentDef: mongodb`, `serviceVersion: 8.0.4`, labels `kb.io/database` and `app.kubernetes.io/instance`). +- MySQL cluster must follow upgraded structure (`kb.io/database: ac-mysql-8.0.30-1`, `clusterDefinitionRef: apecloud-mysql`, `clusterVersionRef: ac-mysql-8.0.30-1`, `tolerations: []`). +- Redis cluster must follow upgraded structure (`componentDef: redis-7`, `componentDef: redis-sentinel-7`, `serviceVersion: 7.2.7`, main data PVC `1Gi`, topology `replication`). +- Database cluster component resources must use `limits(cpu=500m,memory=512Mi)` and `requests(cpu=50m,memory=51Mi)` unless source docs explicitly require otherwise. +- Secret naming: + - MongoDB: `${{ defaults.app_name }}-mongo-mongodb-account-root` (or `${{ defaults.app_name }}-mongodb-mongodb-account-root` when the MongoDB cluster name uses `-mongodb`) + - Redis: `${{ defaults.app_name }}-redis-redis-account-default` (legacy `${{ defaults.app_name }}-redis-account-default` may be accepted for backward compatibility) + - Kafka: `${{ defaults.app_name }}-broker-account-admin` + - Do not use legacy naming outside supported exceptions. + +### Baseline runtime defaults + +Unless source docs explicitly require otherwise, use: + +- container limits: `cpu=200m`, `memory=256Mi` +- container requests: `cpu=20m`, `memory=25Mi` +- `revisionHistoryLimit: 1` +- `automountServiceAccountToken: false` + +### Defaults vs inputs + +- `defaults` for generated values (`app_name`, `app_host`, random passwords/keys). +- `inputs` only for truly user-provided operational values (email/SMTP/external API keys, etc.). +- `inputs.description` must be in English. + +## Validation Commands + +Run all checks before final response: + +1. `python scripts/path_converter.py --self-test` +2. `python scripts/test_check_consistency.py` +3. `python scripts/test_compose_to_template.py` +4. `python scripts/test_check_must_coverage.py` +5. `python scripts/check_consistency.py --skill SKILL.md --references references --rules-file references/rules-registry.yaml` +6. `python scripts/check_consistency.py --skill SKILL.md --references references --rules-file references/rules-registry.yaml --artifacts template//index.yaml` +7. `python scripts/check_must_coverage.py --skill SKILL.md --mapping references/must-rules-map.yaml --rules-file references/rules-registry.yaml` +8. (CI / one-shot) `python scripts/quality_gate.py` (requires `template/*/index.yaml` by default; set `DOCKER_TO_SEALOS_ALLOW_EMPTY_ARTIFACTS=1` only for dev/debug without artifacts) + +`check_consistency.py` is registry-driven. Keep `references/rules-registry.yaml` in sync with implemented rules. +Registry rule entries support `severity` and optional `scope.include_paths` metadata. + +## Output Contract + +When conversion is complete, provide: + +1. brief conversion summary +2. target file path (`template//index.yaml`) +3. complete template YAML +4. key decisions only where ambiguity existed + +Do not create or output README content in this skill. README generation is delegated to another skill. + +## Reference Navigation (Progressive Loading) + +Load only needed references for current task: + +- `references/sealos-specs.md` + - authoritative ordering, labels, App/Ingress/ConfigMap conventions +- `references/conversion-mappings.md` + - Docker→Sealos field-level mappings and edge conversions +- `references/database-templates.md` + - database templates, RBAC structures, secret naming patterns +- `references/frappe-bench.md` + - Frappe/ERPNext/HRMS/bench conversion patterns, init resources, idempotent site bootstrap, and common failure signatures +- `references/example-guide.md` + - examples and pattern walkthroughs (non-authoritative) +- `references/rules-registry.yaml` + - machine-readable validation scope/rules list +- `references/must-rules-map.yaml` + - MUST bullet to enforcement mapping (`rule` or `manual`) for drift control + +## Script Utilities + +- `scripts/path_converter.py` + - convert paths to vn names + - self-test support for regression checks +- `scripts/compose_to_template.py` + - deterministic compose/docs-to-template generator entrypoint + - supports `--kompose-mode auto|always|never` (`always` is default) to reuse `kompose convert` workload shapes + - emits `template//index.yaml` +- `scripts/test_compose_to_template.py` + - regression tests for compose conversion behavior +- `scripts/check_consistency.py` + - registry-driven consistency validator +- `scripts/test_check_consistency.py` + - regression tests for validator behavior +- `scripts/check_must_coverage.py` + - validate MUST bullet coverage mapping against registry rules +- `scripts/test_check_must_coverage.py` + - regression tests for MUST coverage validator +## Edge Policies + +- Never ask users for missing fields; infer from compose/docs and platform conventions. +- Keep App resource in `spec.data.url` format; never use `spec.template`. +- Keep App resource `spec.displayType: normal` and `spec.type: link`; do not infer alternative enum values. +- Keep business-env, object storage, and DB-secret policy consistent with MUST rules. +- Prefer square/circular icon-first logo assets (app icon/favicon/avatar) and avoid rectangular wordmark/text logos. +- Prefer Sealos-managed ingress over bundled edge proxies: if a Traefik gateway is only acting as ingress/front-proxy and at least one business service exists, do not emit Traefik workload resources. +- Prefer gateway TLS termination in Sealos Ingress over in-container TLS: for dual-port HTTP/HTTPS workloads, keep HTTP service port and remove redundant HTTPS/certificate mounts unless official docs require HTTPS backend. +- Never create `template//README.md` or `template//README_zh.md`; only keep README URL references inside `index.yaml` when required by the template schema. +- Prefer fixing references/examples over adding exceptions when conflicts appear. +- If official Kubernetes installation docs/manifests exist for the target app, do not ignore them; use them to refine runtime semantics beyond Compose defaults. +- If the project mentions Frappe, ERPNext, HRMS, or `bench`, load `references/frappe-bench.md` before generating app workloads. diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/references/conversion-mappings.md b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/conversion-mappings.md new file mode 100644 index 00000000..4abae155 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/conversion-mappings.md @@ -0,0 +1,887 @@ +# Docker to Sealos Conversion Mapping Guide + +This document provides detailed mapping rules from Docker Compose configuration to Sealos templates. + +## Dual-Source Input Merging (Compose + Official Kubernetes) + +When an application provides both a Docker Compose file and an official Kubernetes installation method, the conversion must use dual-source merging rather than single-source inference. + +### Merging Principles + +1. Sealos specifications and SKILL MUST rules take priority (security/platform constraints must not be violated) +2. The official Kubernetes installation method takes priority over Compose for application runtime semantics +3. Compose serves as the baseline for service topology and dependencies +4. Generic default values are only used when the above sources are absent + +### Key Alignment Fields + +- First-time initialization and admin bootstrap fields (bootstrap admin/org/user/password) +- External access related fields (domain/port/secure/tls termination assumption) +- Protocol and gateway behavior (Ingress backend protocol, service appProtocol, path routing) +- Health checks and startup ordering (liveness/readiness/startup probe) +- Officially recommended startup parameters and commands + +### Conflict Resolution + +When the official Kubernetes method conflicts with Compose: + +- Preserve Sealos MUST and security rules +- For all other application behavior, default to aligning with the official Kubernetes method +- Record key decisions in the output (only record items with ambiguity) + +## Core Concept Mapping + +### Docker Compose Service → Sealos Resources + +A single service in Docker Compose needs to be converted into multiple Sealos resources: + +```yaml +# Docker Compose +services: + app: + image: myapp:1.0.0 + ports: + - "3000:3000" + volumes: + - ./data:/app/data + environment: + - DB_HOST=postgres +``` + +Converts to: + +```yaml +# Sealos Template +--- +# Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${{ defaults.app_name }} + +--- +# Service +apiVersion: v1 +kind: Service +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + +--- +# Ingress (if public access is required) +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ${{ defaults.app_name }} +``` + +## Image Mapping + +Warning: Example images must use a pinned version, preferring an exact version tag (e.g., `v2.2.0`); only use a digest when a stable version tag cannot be determined. Using `:latest` is prohibited. +Warning: Compose variable image expressions (e.g., `${IMAGE}`, `${IMAGE:-ghcr.io/example/app}`) must not be retained in the final template; they must be resolved to concrete image references during the conversion phase. + +### Docker Compose +```yaml +services: + app: + image: nginx:1.27.2 + # or + build: ./app +``` + +### Sealos Template +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + originImageName: nginx:1.27.2 # Must be added +spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + imagePullSecrets: + - name: ${{ defaults.app_name }} + containers: + - name: ${{ defaults.app_name }} + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent # Must be set +``` + +Notes: +- Always reference the app-scoped image pull Secret `${{ defaults.app_name }}`. +- `sealos-deploy` should create or refresh that Secret automatically from local `gh` CLI credentials when deploying private GHCR images. +- Reusable templates should not expose raw registry credential inputs as user-facing form fields. + +## Port Mapping + +### Docker Compose +```yaml +services: + app: + ports: + - "3000:3000" + - "8080:80" +``` + +> The Sealos gateway terminates TLS at the Ingress layer by default. If Compose exposes both `80` and `443`, and the backend service does not require HTTPS, the conversion should preferentially keep the HTTP port and remove `443`, while also not mounting in-container certificate directories (e.g., `/etc/nginx/ssl`, `/etc/ssl`, `/certs`). + +### Sealos Template + +#### Container Port Configuration +```yaml +spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + imagePullSecrets: + - name: ${{ defaults.app_name }} + containers: + - name: ${{ defaults.app_name }} + ports: + - containerPort: 3000 + - containerPort: 80 +``` + +#### Service Configuration +```yaml +apiVersion: v1 +kind: Service +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +spec: + ports: + - name: tcp-3000 + port: 3000 + targetPort: 3000 + - name: tcp-8080 + port: 8080 + targetPort: 80 + selector: + app: ${{ defaults.app_name }} +``` + +#### Ingress Configuration (Public Access) +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ${{ defaults.app_name }} + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager-domain: ${{ defaults.app_host }} + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: 32m + nginx.ingress.kubernetes.io/server-snippet: | + client_header_buffer_size 64k; + large_client_header_buffers 4 128k; + nginx.ingress.kubernetes.io/ssl-redirect: 'true' + nginx.ingress.kubernetes.io/backend-protocol: HTTP + nginx.ingress.kubernetes.io/client-body-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-send-timeout: '300' + nginx.ingress.kubernetes.io/proxy-read-timeout: '300' + nginx.ingress.kubernetes.io/configuration-snippet: | + if ($request_uri ~* \.(js|css|gif|jpe?g|png)) { + expires 30d; + add_header Cache-Control "public"; + } +spec: + rules: + - host: ${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: ${{ defaults.app_name }} + port: + number: 3000 + tls: + - hosts: + - ${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + secretName: ${{ SEALOS_CERT_SECRET_NAME }} +``` + +#### TLS Offload Normalization (80/443 Dual-Port Scenario) + +```yaml +# Docker Compose +services: + app: + ports: + - "80:80" + - "443:443" + volumes: + - certs:/etc/nginx/ssl + +# After conversion (Sealos) +# - workload/service only retains port 80 +# - Ingress continues to use the platform certificate +# - /etc/nginx/ssl is no longer converted to a PVC mount +``` + +## Environment Variable Mapping + +### Docker Compose +```yaml +services: + app: + environment: + - NODE_ENV=production + - API_KEY=secret123 + - DB_HOST=postgres +``` + +### Sealos Template + +#### Plain Environment Variables +```yaml +spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + imagePullSecrets: + - name: ${{ defaults.app_name }} + containers: + - name: ${{ defaults.app_name }} + env: + - name: NODE_ENV + value: production +``` + +#### Sensitive Values in Business Containers (Non-Database Connection Fields) +```yaml +# Deployment +spec: + template: + spec: + containers: + - name: ${{ defaults.app_name }} + env: + - name: API_KEY + value: ${{ defaults.api_key }} +``` + +Notes: +- Sensitive values for non-database connection fields use `env[].value` (from `defaults` or `inputs`). +- Database connection fields (`endpoint`/`host`/`port`/`username`/`password`) must use `secretKeyRef`. +- Only Kubeblocks database Secrets and object storage Secrets are allowed. + +#### Referencing Database Connections +```yaml +env: + - name: DB_ENDPOINT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: endpoint + - name: DB_HOST + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: host + - name: DB_PORT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: port + - name: DB_USERNAME + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password +``` + +#### URL/DSN Variable Composition (When `endpoint` Is Only `host:port`) +```yaml +env: + - name: SEALOS_DATABASE_POSTGRES_HOST + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: host + - name: SEALOS_DATABASE_POSTGRES_PORT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: port + - name: SEALOS_DATABASE_POSTGRES_USERNAME + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: username + - name: SEALOS_DATABASE_POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + - name: DATABASE_URL + value: postgres://$(SEALOS_DATABASE_POSTGRES_USERNAME):$(SEALOS_DATABASE_POSTGRES_PASSWORD)@$(SEALOS_DATABASE_POSTGRES_HOST):$(SEALOS_DATABASE_POSTGRES_PORT)/postgres +``` + +Notes: +- This pattern should only be used when the source value is a URL/DSN pointing to a recognized database service. +- URL fields such as `DATABASE_URL` are allowed to reference component variables injected by approved DB `secretKeyRef` via `$(VAR)`. +- Assembling database URLs by referencing non-secret source variables is not allowed. + +## Volume Mapping + +### Docker Compose Volumes → Sealos VolumeClaimTemplates + +**Important**: Sealos does not support emptyDir; all storage must be persistent. + +#### Docker Compose +```yaml +services: + app: + volumes: + - ./data:/app/data + - ./config:/app/config +``` + +#### Sealos Template (Using StatefulSet) +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + imagePullSecrets: + - name: ${{ defaults.app_name }} + containers: + - name: ${{ defaults.app_name }} + volumeMounts: + - name: vn-appvn-data + mountPath: /app/data + - name: vn-appvn-config + mountPath: /app/config + volumeClaimTemplates: + - metadata: + annotations: + path: /app/data + value: '1' + name: vn-appvn-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - metadata: + annotations: + path: /app/config + value: '1' + name: vn-appvn-config + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +``` + +### Docker Compose ConfigMap → Sealos ConfigMap + +#### Docker Compose +```yaml +services: + nginx: + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf +``` + +#### Sealos Template +```yaml +# ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: ${{ defaults.app_name }} +data: + vn-etcvn-nginxvn-nginxvn-conf: | + server { + listen 80; + ... + } + +--- +# Deployment +apiVersion: apps/v1 +kind: Deployment +spec: + template: + spec: + containers: + - name: ${{ defaults.app_name }} + volumeMounts: + - name: vn-etcvn-nginxvn-nginxvn-conf + mountPath: /etc/nginx/nginx.conf + subPath: ./etc/nginx/nginx.conf + volumes: + - name: vn-etcvn-nginxvn-nginxvn-conf + configMap: + name: ${{ defaults.app_name }} + items: + - key: vn-etcvn-nginxvn-nginxvn-conf + path: ./etc/nginx/nginx.conf + defaultMode: 420 +``` + +## Database Service Mapping + +### Docker Compose +```yaml +services: + postgres: + image: postgres:16 + environment: + POSTGRES_PASSWORD: secret + volumes: + - pgdata:/var/lib/postgresql/data +``` + +### Sealos Template + +Use the full Kubeblocks Cluster configuration (refer to `database-templates.md`): + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + name: ${{ defaults.app_name }}-pg + labels: + kb.io/database: postgresql-16.4.0 + clusterdefinition.kubeblocks.io/name: postgresql + clusterversion.kubeblocks.io/name: postgresql-16.4.0 +spec: + clusterDefinitionRef: postgresql + clusterVersionRef: postgresql-16.4.0 + # ... full configuration see database-templates.md +``` + +## Service Dependency Mapping + +### Docker Compose +```yaml +services: + app: + depends_on: + - postgres + - redis + environment: + - DB_HOST=postgres + - REDIS_HOST=redis +``` + +### Sealos Template + +#### Inter-Service Communication Using FQDN +```yaml +env: + - name: DB_HOST + value: ${{ defaults.app_name }}-pg-postgresql.${{ SEALOS_NAMESPACE }}.svc.cluster.local + - name: REDIS_HOST + value: ${{ defaults.app_name }}-redis-redis-redis.${{ SEALOS_NAMESPACE }}.svc.cluster.local +``` + +#### Or Using Secret +```yaml +env: + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + - name: DB_URL + value: postgresql://postgres:$(POSTGRES_PASSWORD)@${{ defaults.app_name }}-pg-postgresql.${{ SEALOS_NAMESPACE }}.svc:5432/mydb +``` + +## Resource Limits Mapping + +### Docker Compose +```yaml +services: + app: + deploy: + resources: + limits: + cpus: '1' + memory: 1G + reservations: + cpus: '0.5' + memory: 512M +``` + +### Sealos Template +```yaml +spec: + template: + spec: + containers: + - name: ${{ defaults.app_name }} + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 500m + memory: 512Mi +``` + +## Health Check Mapping + +Conversion priority: +1. When Docker Compose has a `healthcheck`, convert it to `livenessProbe` + `readinessProbe` +2. When Compose does not provide one but the official documentation clearly specifies a health endpoint/command, `livenessProbe` + `readinessProbe` must still be generated +3. For applications with slow initial startup (e.g., those that need to initialize a database), a `startupProbe` must also be generated to avoid premature failure during startup + +### Docker Compose +```yaml +services: + app: + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 +``` + +### Official Health Check Example (authentik) +```yaml +containers: + - image: ghcr.io/goauthentik/server:2025.12.3 + imagePullPolicy: IfNotPresent + startupProbe: + httpGet: + path: /-/health/ready/ + port: 9000 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 90 + livenessProbe: + httpGet: + path: /-/health/live/ + port: 9000 + readinessProbe: + httpGet: + path: /-/health/ready/ + port: 9000 +``` + +### Sealos Template +```yaml +spec: + template: + spec: + containers: + - name: ${{ defaults.app_name }} + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 +``` + +## Command and Arguments Mapping + +### Docker Compose +```yaml +services: + app: + command: ["npm", "start"] + # or + entrypoint: /app/start.sh + command: arg1 arg2 +``` + +### Sealos Template +```yaml +spec: + template: + spec: + containers: + - name: ${{ defaults.app_name }} + command: ["npm", "start"] + # or + command: ["/app/start.sh"] + args: ["arg1", "arg2"] +``` + +### Volume-Dependent Arguments (Important!) + +Docker Compose `command:` or `args` may reference paths that only exist because of a host volume mount in Compose. These paths may **not** exist inside the container image itself. + +**Example — compose mounts a host dir for log output:** +```yaml +# Docker Compose +services: + app: + command: --log-dir /app/logs + volumes: + - ./logs:/app/logs # host mount creates /app/logs +``` + +If the Sealos template does not provision a matching volume, the `/app/logs` directory will not exist and the container will crash at startup (e.g., `mkdir /app/logs: no such file or directory`). + +**Resolution — check before converting:** +1. For each path referenced in `command:`/`args`, check whether it comes from a Compose `volumes:` mount. +2. If the path is a **log/data output directory** that only exists via host mount: + - **Option A (preferred):** Drop the argument entirely — let the app use its built-in defaults (most apps log to stdout by default). + - **Option B:** Add a matching `volumeClaimTemplates` (StatefulSet) or `emptyDir`-equivalent PVC to ensure the path exists. +3. If the path is an **essential config/script file** mounted from host → convert to ConfigMap mount instead. +4. Paths to executables or tools already inside the image (e.g., `npm start`, `/app/start.sh` from Dockerfile COPY) are safe to keep. + +## Network Mode Mapping + +### Built-in Edge Gateway (Traefik) Handling + +When Compose includes both Traefik and business services, prefer using the Sealos platform Ingress capability and do not retain Traefik as an in-template workload. + +Handling rules: + +- If a service name or image is identifiable as Traefik, and at least one non-database business service exists, skip Traefik resource generation. +- The primary access entry point should target the business service (typically the first business service) via its Service, with the public domain exposed through Sealos Ingress. +- Only when the application contains only Traefik (no other business services) should Traefik be retained as a fallback, to avoid generating empty workloads. + +Motivation: + +- Avoid the additional forwarding complexity introduced by a dual-gateway setup (Traefik + Sealos Ingress). +- Reduce the risk of port, routing, and TLS configuration drift, making the template better aligned with Sealos platform capabilities. + +### Docker Compose +```yaml +services: + app: + network_mode: host + # or + ports: + - "3000:3000" +``` + +### Sealos Template + +Sealos does not support host network mode; all access uses Service + Ingress: + +```yaml +# Service (cluster-internal access) +apiVersion: v1 +kind: Service +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +spec: + ports: + - name: tcp-3000 + port: 3000 + selector: + app: ${{ defaults.app_name }} + +--- +# Ingress (public access) +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ${{ defaults.app_name }} + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager-domain: ${{ defaults.app_host }} + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: 32m + nginx.ingress.kubernetes.io/server-snippet: | + client_header_buffer_size 64k; + large_client_header_buffers 4 128k; + nginx.ingress.kubernetes.io/ssl-redirect: 'true' + nginx.ingress.kubernetes.io/backend-protocol: HTTP + nginx.ingress.kubernetes.io/client-body-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-send-timeout: '300' + nginx.ingress.kubernetes.io/proxy-read-timeout: '300' + nginx.ingress.kubernetes.io/configuration-snippet: | + if ($request_uri ~* \.(js|css|gif|jpe?g|png)) { + expires 30d; + add_header Cache-Control "public"; + } +spec: + rules: + - host: ${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: ${{ defaults.app_name }} + port: + number: 3000 +``` + +## Object Storage Mapping + +### Docker Compose (Using Minio) +```yaml +services: + minio: + image: minio/minio + command: server /data + volumes: + - minio-data:/data +``` + +### Sealos Template +```yaml +apiVersion: objectstorage.sealos.io/v1 +kind: ObjectStorageBucket +metadata: + name: ${{ defaults.app_name }} +spec: + policy: private + +--- +# Using object storage in the application +spec: + template: + spec: + containers: + - name: ${{ defaults.app_name }} + env: + - name: S3_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: object-storage-key + key: accessKey + - name: S3_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: object-storage-key + key: secretKey + - name: S3_BUCKET + valueFrom: + secretKeyRef: + name: object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }} + key: bucket +``` + +Bucket-scoped object-storage secrets may append an additional lowercase suffix when one app needs multiple bucket values, for example `object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }}-public`. Env names ending in `_BUCKET` may reference those bucket-scoped secrets. + +## CronJob Mapping + +Any generated `CronJob` must include Sealos cron labels: + +```yaml +metadata: + labels: + cloud.sealos.io/cronjob: + cronjob-launchpad-name: "" + cronjob-type: image +``` + +## Common Patterns Summary + +### Single-Container Application +- Docker Service → Deployment + Service + Ingress + +### Multi-Container Application +- Each Docker Service → Independent Deployment + Service +- The main application uses `${{ defaults.app_name }}` +- Other components use `${{ defaults.app_name }}-` + +### Database Services +- Docker postgres/mysql/mongo/redis → Kubeblocks Cluster + ServiceAccount + Role + RoleBinding + +### Persistent Storage +- Docker volumes → StatefulSet + volumeClaimTemplates + +### Configuration Files +- Docker config files → ConfigMap (using vn- naming convention) + +### Public URL Configuration + +Many web apps need their external URL configured to avoid hardcoded `localhost` references. +Without this, frontend API calls, OAuth callbacks, and webhook URLs will break in production. + +#### Detection +Check source code/docs for: +- Env vars: `BASE_URL`, `SITE_URL`, `APP_URL`, `NEXTAUTH_URL`, `PUBLIC_URL`, `EXTERNAL_URL`, `HOSTNAME` +- Config files: node-config (`config/default.json`), PHP config, Rails `config/environments/production.rb` +- Code patterns: `getConfig(.*[Uu]rl`, `homeUrl`, `baseUrl`, `siteUrl`, fallback to `http://localhost` + +#### Strategy A: Env Var (preferred when supported) +When the app reads its public URL from an environment variable: +```yaml +- name: APP_URL # use the app's actual env var name + value: https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} +``` + +#### Strategy B: ConfigMap (for file-based config systems) +When the app reads its public URL from a config file (e.g., node-config, PHP config): + +1. Create ConfigMap with the minimal config override containing only the public URL +2. Mount to the app's config directory using `subPath` to avoid overwriting other files +3. Follow standard ConfigMap naming/label conventions + +```yaml +# ConfigMap — only include the minimal config needed for public URL +apiVersion: v1 +kind: ConfigMap +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +data: + : | + + +# Deployment volumeMount — use subPath to mount single file +volumeMounts: + - name: app-config + mountPath: / + subPath: + +# Deployment volume +volumes: + - name: app-config + configMap: + name: ${{ defaults.app_name }} +``` + +Real-world examples: see `skills/sealos-deploy/knowledge/lessons-learned.md` (EverShop case study) + +### Sensitive Information +- Docker business env vars → `env[].value` (`defaults`/`inputs`) diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/references/database-templates.md b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/database-templates.md new file mode 100644 index 00000000..d593cc43 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/database-templates.md @@ -0,0 +1,685 @@ +# Database Template Reference + +This document contains complete Sealos template configurations for various databases, intended as a reference during conversion. + +## PostgreSQL Full Template + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + labels: + kb.io/database: postgresql-16.4.0 + clusterdefinition.kubeblocks.io/name: postgresql + clusterversion.kubeblocks.io/name: postgresql-16.4.0 + name: ${{ defaults.app_name }}-pg +spec: + affinity: + podAntiAffinity: Preferred + tenancy: SharedNode + clusterDefinitionRef: postgresql + clusterVersionRef: postgresql-16.4.0 + componentSpecs: + - componentDefRef: postgresql + disableExporter: true + enabledLogs: + - running + name: postgresql + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + serviceAccountName: ${{ defaults.app_name }}-pg + switchPolicy: + type: Noop + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: openebs-backup + terminationPolicy: Delete + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-pg + app.kubernetes.io/instance: ${{ defaults.app_name }}-pg + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-pg + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-pg + app.kubernetes.io/instance: ${{ defaults.app_name }}-pg + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-pg +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-pg + app.kubernetes.io/instance: ${{ defaults.app_name }}-pg + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-pg +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${{ defaults.app_name }}-pg +subjects: + - kind: ServiceAccount + name: ${{ defaults.app_name }}-pg +``` + +### PostgreSQL Database Initialization Job + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: ${{ defaults.app_name }}-pg-init +spec: + backoffLimit: 3 + template: + spec: + containers: + - name: pgsql-init + image: postgres:16-alpine + imagePullPolicy: IfNotPresent + env: + - name: PG_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + - name: PG_ENDPOINT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: endpoint + - name: PG_DATABASE + value: + command: + - /bin/sh + - -c + - | + set -eu + for i in $(seq 1 60); do + if pg_isready -h "${PG_ENDPOINT%:*}" -p "${PG_ENDPOINT##*:}" -U postgres -d postgres >/dev/null 2>&1; then + break + fi + sleep 2 + done + pg_isready -h "${PG_ENDPOINT%:*}" -p "${PG_ENDPOINT##*:}" -U postgres -d postgres >/dev/null 2>&1 + if ! psql "postgresql://postgres:$(PG_PASSWORD)@$(PG_ENDPOINT)/postgres" -tAc "SELECT 1 FROM pg_database WHERE datname='$(PG_DATABASE)'" | grep -q 1; then + psql "postgresql://postgres:$(PG_PASSWORD)@$(PG_ENDPOINT)/postgres" -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$(PG_DATABASE)\";" + fi + restartPolicy: OnFailure + ttlSecondsAfterFinished: 300 +``` + +## MySQL Full Template + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + labels: + kb.io/database: ac-mysql-8.0.30-1 + clusterdefinition.kubeblocks.io/name: apecloud-mysql + clusterversion.kubeblocks.io/name: ac-mysql-8.0.30-1 + name: ${{ defaults.app_name }}-mysql +spec: + affinity: + nodeLabels: {} + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: + - kubernetes.io/hostname + clusterDefinitionRef: apecloud-mysql + clusterVersionRef: ac-mysql-8.0.30-1 + componentSpecs: + - componentDefRef: mysql + monitor: true + name: mysql + noCreatePDB: false + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + serviceAccountName: ${{ defaults.app_name }}-mysql + switchPolicy: + type: Noop + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: openebs-backup + terminationPolicy: Delete + tolerations: [] + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mysql + app.kubernetes.io/instance: ${{ defaults.app_name }}-mysql + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mysql + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mysql + app.kubernetes.io/instance: ${{ defaults.app_name }}-mysql + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mysql +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mysql + app.kubernetes.io/instance: ${{ defaults.app_name }}-mysql + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mysql +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${{ defaults.app_name }}-mysql +subjects: + - kind: ServiceAccount + name: ${{ defaults.app_name }}-mysql +``` + +## MongoDB Full Template + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + labels: + kb.io/database: mongodb-8.0.4 + app.kubernetes.io/instance: ${{ defaults.app_name }}-mongo + name: ${{ defaults.app_name }}-mongo +spec: + affinity: + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: + - kubernetes.io/hostname + componentSpecs: + - componentDef: mongodb + name: mongodb + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + serviceAccountName: ${{ defaults.app_name }}-mongo + serviceVersion: 8.0.4 + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: openebs-backup + terminationPolicy: Delete + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mongo + app.kubernetes.io/instance: ${{ defaults.app_name }}-mongo + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mongo + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mongo + app.kubernetes.io/instance: ${{ defaults.app_name }}-mongo + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mongo +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mongo + app.kubernetes.io/instance: ${{ defaults.app_name }}-mongo + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mongo +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${{ defaults.app_name }}-mongo +subjects: + - kind: ServiceAccount + name: ${{ defaults.app_name }}-mongo +``` + +## Redis Full Template + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + labels: + kb.io/database: redis-7.2.7 + app.kubernetes.io/instance: ${{ defaults.app_name }}-redis + app.kubernetes.io/version: 7.2.7 + clusterversion.kubeblocks.io/name: redis-7.2.7 + clusterdefinition.kubeblocks.io/name: redis + name: ${{ defaults.app_name }}-redis +spec: + affinity: + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: + - kubernetes.io/hostname + clusterDefinitionRef: redis + componentSpecs: + - componentDef: redis-7 + name: redis + replicas: 1 + enabledLogs: + - running + env: + - name: CUSTOM_SENTINEL_MASTER_NAME + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + serviceAccountName: ${{ defaults.app_name }}-redis + serviceVersion: 7.2.7 + switchPolicy: + type: Noop + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: openebs-backup + - componentDef: redis-sentinel-7 + name: redis-sentinel + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + serviceAccountName: ${{ defaults.app_name }}-redis + serviceVersion: 7.2.7 + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + terminationPolicy: Delete + topology: replication + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-redis + app.kubernetes.io/instance: ${{ defaults.app_name }}-redis + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-redis + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-redis + app.kubernetes.io/instance: ${{ defaults.app_name }}-redis + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-redis +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-redis + app.kubernetes.io/instance: ${{ defaults.app_name }}-redis + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-redis +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${{ defaults.app_name }}-redis +subjects: + - kind: ServiceAccount + name: ${{ defaults.app_name }}-redis +``` + +## Kafka Full Template + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + finalizers: + - cluster.kubeblocks.io/finalizer + labels: + kb.io/database: kafka-3.3.2 + clusterdefinition.kubeblocks.io/name: kafka + clusterversion.kubeblocks.io/name: kafka-3.3.2 + annotations: + kubeblocks.io/extra-env: >- + {"KB_KAFKA_ENABLE_SASL":"false","KB_KAFKA_BROKER_HEAP":"-XshowSettings:vm -XX:MaxRAMPercentage=100 -Ddepth=64","KB_KAFKA_CONTROLLER_HEAP":"-XshowSettings:vm -XX:MaxRAMPercentage=100 -Ddepth=64","KB_KAFKA_PUBLIC_ACCESS":"false"} + name: ${{ defaults.app_name }}-broker +spec: + terminationPolicy: Delete + componentSpecs: + - name: broker + componentDef: kafka-broker + tls: false + replicas: 1 + affinity: + podAntiAffinity: Preferred + topologyKeys: + - kubernetes.io/hostname + tenancy: SharedNode + tolerations: + - key: kb-data + operator: Equal + value: 'true' + effect: NoSchedule + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - name: metadata + spec: + storageClassName: null + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - name: controller + componentDefRef: controller + componentDef: kafka-controller + tls: false + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + volumeClaimTemplates: + - name: metadata + spec: + storageClassName: null + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - name: metrics-exp + componentDef: kafka-exporter + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-broker + app.kubernetes.io/instance: ${{ defaults.app_name }}-broker + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-broker + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-broker + app.kubernetes.io/instance: ${{ defaults.app_name }}-broker + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-broker +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-broker + app.kubernetes.io/instance: ${{ defaults.app_name }}-broker + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-broker +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${{ defaults.app_name }}-broker +subjects: + - kind: ServiceAccount + name: ${{ defaults.app_name }}-broker +``` + +## Database Connection Configuration + +### Upgrade Baseline (Database Upgrade Documentation) + +The following specifications are consistent with the database upgrade documentation: + +- Database connection fields (`endpoint`/`host`/`port`/`username`/`password`) in application containers must be obtained via `secretKeyRef`; Redis host/port may use the Sealos Redis Service FQDN plus `6379` when the secret only exposes credentials, and MongoDB URLs may use the Sealos MongoDB Service FQDN plus `27017` when the secret only exposes credentials +- PostgreSQL Cluster uses `postgresql-16.4.0` and includes `kb.io/database`, `disableExporter: true`, `enabledLogs: [running]` +- Secret naming upgrades: + - `xxx-redis-conn-credential` -> `xxx-redis-redis-account-default` + - `xxx-mongo-conn-credential` -> `xxx-mongo-mongodb-account-root` (or `xxx-mongodb-mongodb-account-root` when the Cluster name uses `xxx-mongodb`) + - `xxx-conn-credential` (kafka) -> `xxx-broker-account-admin` + +### Secret Naming Conventions + +- PostgreSQL: `${{ defaults.app_name }}-pg-conn-credential` +- MySQL: `${{ defaults.app_name }}-mysql-conn-credential` +- MongoDB: `${{ defaults.app_name }}-mongo-mongodb-account-root` (or `${{ defaults.app_name }}-mongodb-mongodb-account-root` when the MongoDB Cluster name uses `${{ defaults.app_name }}-mongodb`) +- Redis: `${{ defaults.app_name }}-redis-redis-account-default` (legacy `${{ defaults.app_name }}-redis-account-default` may be accepted for backward compatibility) +- Kafka: `${{ defaults.app_name }}-broker-account-admin` + +**Important — Redis naming pattern:** +The Redis secret and service names contain a "double redis" because Kubeblocks follows the pattern `--account-default` for secrets and `--` for ClusterIP services: +- Cluster name: `${{ defaults.app_name }}-redis` +- Component name: `redis` (defined in `componentSpecs[].name`) +- Secret: `${{ defaults.app_name }}-redis` + `-redis-account-default` = `...-redis-redis-account-default` +- ClusterIP Service: `${{ defaults.app_name }}-redis` + `-redis` + `-redis` = `...-redis-redis-redis` +- Service FQDN: `${{ defaults.app_name }}-redis-redis-redis.${{ SEALOS_NAMESPACE }}.svc` + +This same pattern applies to other databases (e.g., PostgreSQL service is `-pg-postgresql`, MySQL is `-mysql-mysql`). + +### Keys Included in Secrets + +PostgreSQL/MySQL/MongoDB/Kafka secrets usually contain: +- `endpoint`: Full connection endpoint (host:port) +- `host`: Hostname +- `password`: Password +- `port`: Port number +- `username`: Username + +Redis default account secrets usually contain: +- `username` +- `password` + +### Environment Variable Configuration Examples + +```yaml +env: + # PostgreSQL + - name: POSTGRES_ENDPOINT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: endpoint + - name: POSTGRES_HOST + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: host + - name: POSTGRES_PORT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: port + - name: POSTGRES_USERNAME + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: username + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + + # MySQL + - name: MYSQL_HOST + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-mysql-conn-credential + key: host + - name: MYSQL_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-mysql-conn-credential + key: password + + # MongoDB (credential secret + fixed Service FQDN) + - name: MONGO_USERNAME + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-mongo-mongodb-account-root + key: username + - name: MONGO_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-mongo-mongodb-account-root + key: password + - name: MONGODB_URI + value: mongodb://$(MONGO_USERNAME):$(MONGO_PASSWORD)@${{ defaults.app_name }}-mongo-mongodb.${{ SEALOS_NAMESPACE }}.svc:27017/app?authSource=admin + + # Redis + - name: REDIS_HOST + value: ${{ defaults.app_name }}-redis-redis-redis.${{ SEALOS_NAMESPACE }}.svc.cluster.local + - name: REDIS_PORT + value: "6379" + - name: REDIS_USERNAME + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-redis-redis-account-default + key: username + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-redis-redis-account-default + key: password +``` diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/references/example-guide.md b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/example-guide.md new file mode 100644 index 00000000..dfd8bfa8 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/example-guide.md @@ -0,0 +1,1572 @@ +# Template Guide + +![FastGPT Page](docs/images/fastgpt.png) + +Using FastGPT as an example, this guide explains how to create a template with code. This example assumes you already have some understanding of Kubernetes resource files and only explains parameters specific to templates. The template file is mainly divided into two parts. + +![structure](docs/images/structure-black.png#gh-dark-mode-only)![structure](docs/images/structure-white.png#gh-light-mode-only) + +## Part 1: `Metadata CR` + +```yaml +apiVersion: app.sealos.io/v1 +kind: Template +metadata: + name: fastgpt +spec: + title: 'FastGpt' + url: 'https://fastgpt.run/' + gitRepo: 'https://github.com/labring/FastGPT' + author: 'sealos' + description: 'Fast GPT allows you to use your own openai API KEY to quickly call the openai interface, currently integrating Gpt35, Gpt4 and embedding. You can build your own knowledge base.' + readme: 'https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template/fastgpt/README.md' + icon: 'https://avatars.githubusercontent.com/u/50446880?s=96&v=4' + templateType: inline + defaults: + app_name: + type: string + value: fastgpt-${{ random(8) }} + app_host: + type: string + value: ${{ random(8) }} + inputs: + root_passowrd: + description: 'Set root password. login: username: root, password: root_passowrd' + type: string + default: ${{ SEALOS_NAMESPACE }} + required: true + openai_key: + description: 'openai api key' + type: string + default: '' + required: true + database_type: + description: 'type of database' + required: false + type: choice + default: 'mysql' + options: + - sqlite + - mysql +``` + +As shown in the code, the Metadata CR is a standard Kubernetes custom resource type. The table below lists the fields that need to be filled in. + +| Field | Description | +| :---------------| :----------------------------------------------------------- | +| `templateType` | `inline` indicates this is an inline template where all yaml files are integrated into a single file. | +| `defaults` | Defines default values to be populated into the resource files, such as the application name (app_name), domain (app_host), etc. | +| `inputs` | Defines some parameters that users need when deploying the application, such as email, API-KEY, etc. If there are none, this can be omitted. | + +### Explanation: `Variables` + +Any characters surrounded by `${{ }}` are variables. Variables are divided into the following types: + +1. `SEALOS_` all-uppercase predefined system built-in variables, such as `${{ SEALOS_NAMESPACE }}`, are variables provided by Sealos itself. For all currently supported system variables, see [System Variables](#built-in-system-variables-and-functions). +2. `functions()` functions, such as `${{ random(8) }}`, are functions provided by Sealos itself. For all currently supported functions, see [Functions](#built-in-system-variables-and-functions). +3. `defaults` is a list of names and values that are resolved when populating random values. +4. `inputs` are filled in by the user when deploying the application, and the inputs will be rendered as a frontend form. + +### Explanation: `Defaults` + +`spec.defaults` is a mapping of names, types, and values that are populated as default values when the template is parsed. + +| Name | Description | +| :-------| :---------- | +| `type` | `string` or `number` indicates the type of the variable. The only difference is that string types will be quoted during rendering, while number types will not. | +| `value` | The value of the variable. If the value is a function, it will be rendered. | + +**In the current version implementation, `defaults` must have an `app_name` field, and it must contain a `${{ random(8) }}` random number as the unique name for the application, otherwise an error will occur.** + +### Explanation: `Inputs` + +`spec.defaults` is a defined object mapping that is parsed and displayed as form inputs for user interaction. + +| Name | Description | +| :-------| :---------- | +| `description` | The description of the input. It will be rendered as the input placeholder. | +| `default` | The default value of the input. | +| `required` | Whether the input is required. | +| `type` | Must be one of `string` \| `number` \| `choice` \| `boolean` | +| `options`? | When the type is `choice`, sets the list of available options. | +| `if`? | A JavaScript expression that controls whether this option is enabled. | + +The inputs shown above will be rendered as form inputs on the frontend: + + + + + + + + + +
Template View
+ +```yaml +inputs: + root_passowrd: + description: 'Set root password. login: username: root, password: root_passowrd' + type: string + default: '' + required: true + openai_key: + description: 'openai api key' + type: string + default: '' + required: true +``` + + + +![render inputs](docs/images/render-inputs_zh.png) + +
+ +#### Usage of the `if` Parameter in `Inputs` + +- The form supports dynamic rendering, controlling whether a form item is enabled through the `if` parameter. +- The content of the parameter is an expression; do not wrap it with `${{ }}`. +- When the expression result is `true`, the parameter is rendered; when the result is `false`, the parameter is not rendered, and the corresponding `required` parameter will not take effect either. +- If the result is not a boolean value, it will be coerced to a boolean value. + +### Built-in System Variables and Functions + +The Sealos template engine uses the `${{ expression }}` syntax to parse expressions. + +- `expression` is a valid JavaScript expression. +- Built-in Sealos variables and functions can be accessed within the expression. + +Sealos provides some built-in system variables and functions for convenient use in templates. + +#### Built-in System Variables + +- `${{ SEALOS_NAMESPACE }}` The namespace where the Sealos user deploys. +- `${{ SEALOS_CLOUD_DOMAIN }}` The domain suffix of the Sealos cluster. +- `${{ SEALOS_CERT_SECRET_NAME }}` The secret name used by Sealos to store TLS certificates. +- `${{ SEALOS_SERVICE_ACCOUNT }}` The SA of the Sealos user. + +#### Built-in System Functions + +- `${{ random(length) }}` Generates a random string of the specified `length`. +- `${{ base64(expression) }}` Encodes the expression result into base64 format. + - `${{ base64('hello world') }}` will return `aGVsbG8gd29ybGQ=`. + - You can also reference variables `${{ base64(inputs.secret) }}`. + +> Note +> +> You cannot use `${{ inputs.enabled }}` to determine whether an option is enabled, because `enabled` is a string, not a boolean value. +> +> You need to use `${{ inputs.enabled === 'true' }}` to determine whether an option is enabled. + +#### Conditional Rendering + +The Sealos template engine supports conditional rendering using `${{ if(expression) }}`, `${{ elif(expression) }}`, `${{ else() }}`, and `${{ endif() }}`. + +- Conditional rendering is a special type of built-in system function. +- Conditional statements must occupy a line by themselves and cannot be on the same line as other content. +- Conditional expressions must return a boolean value (`true` or `false`); otherwise, they will be coerced to a boolean value. +- Cross-YAML-list rendering is allowed. +- `Template CR` does not support conditional rendering. + +**Example:** + +```yaml +${{ if(inputs.enableIngress === 'true') }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +... +${{ endif() }} +``` + +This code means that the Ingress resource will only be rendered when `inputs.enableIngress` is `true`. + +
+ +A relatively complete example + +```yaml +apiVersion: app.sealos.io/v1 +kind: Template +metadata: + name: chatgpt-next-web +spec: + title: 'chatgpt-next-web' + url: 'https://github.com/Yidadaa/ChatGPT-Next-Web' + gitRepo: 'https://github.com/Yidadaa/ChatGPT-Next-Web' + author: 'Sealos' + description: 'One-click free deployment of your cross-platform private ChatGPT application' + readme: 'https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template/chatgpt-next-web/README.md' + icon: 'https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/docs/images/icon.svg' + templateType: inline + categories: + - ai + defaults: + app_host: + type: string + value: ${{ random(8) }} + app_name: + type: string + value: chatgpt-next-web-${{ random(8) }} + inputs: + DOMAIN: + description: "Custom domain, need to CNAME to: ${{ defaults.app_host + '.' + SEALOS_CLOUD_DOMAIN }}" + type: string + default: '' + required: false + OPENAI_API_KEY: + description: 'This is your API key obtained from the OpenAI account page. Separate multiple keys with commas to enable random rotation among these keys' + type: string + default: '' + required: true + HIDE_USER_API_KEY: + description: 'Check this if you do not want users to fill in their own API Key' + type: boolean + default: 'false' + required: false + AUZRE_ENABLE: + description: 'Enable Azure' + type: boolean + default: 'false' + required: false + AZURE_API_KEY: + description: 'Azure Key' + type: string + default: '' + required: true + if: inputs.AUZRE_ENABLE === 'true' + AZURE_URL: + description: 'Azure Deployment URL' + type: string + default: 'https://{azure-resource-url}/openai/deployments/{deploy-name}' + required: true + if: inputs.AUZRE_ENABLE === 'true' + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${{ defaults.app_name }} + annotations: + originImageName: yidadaa/chatgpt-next-web:v2.12.4 + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + app: ${{ defaults.app_name }} +spec: + replicas: 1 + revisionHistoryLimit: 1 + selector: + matchLabels: + app: ${{ defaults.app_name }} + template: + metadata: + labels: + app: ${{ defaults.app_name }} + spec: + automountServiceAccountToken: false + imagePullSecrets: + - name: ${{ defaults.app_name }} + containers: + - name: ${{ defaults.app_name }} + image: yidadaa/chatgpt-next-web:v2.12.4 + env: + - name: OPENAI_API_KEY + value: ${{ inputs.OPENAI_API_KEY }} + ${{ if(inputs.HIDE_USER_API_KEY === 'true') }} + - name: HIDE_USER_API_KEY + value: '1' + ${{ endif() }} + ${{ if(inputs.AUZRE_ENABLE === 'true') }} + - name: AZURE_URL + value: ${{ inputs.AZURE_URL }} + - name: AZURE_API_KEY + value: ${{ inputs.AZURE_API_KEY }} + ${{ endif() }} + ports: + - containerPort: 3000 +--- +apiVersion: v1 +kind: Service +metadata: + name: ${{ defaults.app_name }} + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +spec: + ports: + - port: 3000 + selector: + app: ${{ defaults.app_name }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ${{ defaults.app_name }} + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager-domain: ${{ defaults.app_host }} + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: 32m + nginx.ingress.kubernetes.io/server-snippet: | + client_header_buffer_size 64k; + large_client_header_buffers 4 128k; + nginx.ingress.kubernetes.io/ssl-redirect: 'true' + nginx.ingress.kubernetes.io/backend-protocol: HTTP + nginx.ingress.kubernetes.io/client-body-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-send-timeout: '300' + nginx.ingress.kubernetes.io/proxy-read-timeout: '300' + nginx.ingress.kubernetes.io/configuration-snippet: | + if ($request_uri ~* \.(js|css|gif|jpe?g|png)) { + expires 30d; + add_header Cache-Control "public"; + } +spec: + rules: + - host: ${{ inputs.DOMAIN || defaults.app_host + '.' + SEALOS_CLOUD_DOMAIN }} + http: + paths: + - pathType: Prefix + path: /()(.*) + backend: + service: + name: ${{ defaults.app_name }} + port: + number: 3000 + tls: + - hosts: + - ${{ inputs.DOMAIN || defaults.app_host + '.' + SEALOS_CLOUD_DOMAIN }} + secretName: "${{ inputs.DOMAIN ? defaults.app_name + '-cert' : SEALOS_CERT_SECRET_NAME }}" + +--- +${{ if(inputs.DOMAIN !== '') }} +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: ${{ defaults.app_name }} + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: admin@sealos.io + privateKeySecretRef: + name: letsencrypt-prod + solvers: + - http01: + ingress: + class: nginx + serviceType: ClusterIP + +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: ${{ defaults.app_name }}-cert + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +spec: + secretName: ${{ defaults.app_name }}-cert + dnsNames: + - ${{ inputs.DOMAIN }} + issuerRef: + name: ${{ defaults.app_name }} + kind: Issuer +${{ endif() }} +``` + +
+ +## Part 2: `Application Resource Files` + +This part typically consists of a set of resource types: + +- Application `Deployment`, `StatefulSet`, `Service` +- External Access `Ingress` +- Underlying Dependencies `Database`, `Object Storage` + +Each resource can be repeated any number of times, in no particular order. + +### Explanation: `Application` + +An application is a list composed of multiple `Deployment`, `StatefulSet`, `Service` and/or `Job`, `Secret`, `ConfigMap`, `Custom Resource`. + +
+ +Code + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${{ defaults.app_name }} + annotations: + originImageName: c121914yu/fast-gpt:v1.0.0 + deploy.cloud.sealos.io/minReplicas: '1' + deploy.cloud.sealos.io/maxReplicas: '1' + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + app: ${{ defaults.app_name }} +spec: + replicas: 1 + revisionHistoryLimit: 1 + selector: + matchLabels: + app: ${{ defaults.app_name }} + template: + metadata: + labels: + app: ${{ defaults.app_name }} + spec: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${{ defaults.app_name }} + annotations: + originImageName: c121914yu/fast-gpt:v1.0.0 + deploy.cloud.sealos.io/minReplicas: '1' + deploy.cloud.sealos.io/maxReplicas: '1' + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + app: ${{ defaults.app_name }} +spec: + replicas: 1 + revisionHistoryLimit: 1 + selector: + matchLabels: + app: ${{ defaults.app_name }} + template: + metadata: + labels: + app: ${{ defaults.app_name }} + spec: + containers: + - name: ${{ defaults.app_name }} + image: c121914yu/fast-gpt:v1.0.0 + env: + - name: MONGO_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-mongodb-account-root + key: password + - name: PG_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + - name: ONEAPI_URL + value: ${{ defaults.app_name }}-key.${{ SEALOS_NAMESPACE }}.svc.cluster.local:3000/v1 + - name: ONEAPI_KEY + value: sk-xxxxxx + - name: DB_MAX_LINK + value: 5 + - name: MY_MAIL + value: ${{ inputs.mail }} + - name: MAILE_CODE + value: ${{ inputs.mail_code }} + - name: TOKEN_KEY + value: fastgpttokenkey + - name: ROOT_KEY + value: rootkey + - name: MONGODB_URI + value: >- + mongodb://root:$(MONGO_PASSWORD)@${{ defaults.app_name }}-mongo-mongo.${{ SEALOS_NAMESPACE }}.svc:27017 + - name: MONGODB_NAME + value: fastgpt + - name: PG_HOST + value: ${{ defaults.app_name }}-pg-pg.${{ SEALOS_NAMESPACE }}.svc + - name: PG_USER + value: postgres + - name: PG_PORT + value: '5432' + - name: PG_DB_NAME + value: postgres + resources: + requests: + cpu: 100m + memory: 102Mi + limits: + cpu: 1000m + memory: 1024Mi + command: [] + args: [] + ports: + - containerPort: 3000 + imagePullPolicy: IfNotPresent + volumeMounts: [] + volumes: [] + +--- +apiVersion: v1 +kind: Service +metadata: + name: ${{ defaults.app_name }} + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +spec: + ports: + - port: 3000 + selector: + app: ${{ defaults.app_name }} +``` + +
+ +The frequently changed fields are as follows: + +| Field | Description | +| :--------------------------- | :----------------------------------------------------------- | +| `metadata.annotations`
`metadata.labels` | Change to match Launchpad's requirements, such as `originImageName`, `minReplicas`, `maxReplicas`. | +| `spec.containers[].image` | Change to your Docker image. | +| `spec.containers[].env` | Configure environment variables for the container. | +| `spec.containers[].ports.containerPort` | Change to the port corresponding to your Docker image. | +| `${{ defaults.app_name }}` | You can use `${{ defaults.xxxx }}`\|`${{ inputs.xxxx }}` variables to set parameters defined in the `Template CR`. + +### Explanation: `External Access` + +If the application needs to be accessed externally, you need to add the following code: + +
+ +Code + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ${{ defaults.app_name }} + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager-domain: ${{ defaults.app_host }} + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: 32m + nginx.ingress.kubernetes.io/server-snippet: | + client_header_buffer_size 64k; + large_client_header_buffers 4 128k; + nginx.ingress.kubernetes.io/ssl-redirect: 'true' + nginx.ingress.kubernetes.io/backend-protocol: HTTP + nginx.ingress.kubernetes.io/client-body-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-send-timeout: '300' + nginx.ingress.kubernetes.io/proxy-read-timeout: '300' + nginx.ingress.kubernetes.io/configuration-snippet: | + if ($request_uri ~* \.(js|css|gif|jpe?g|png)) { + expires 30d; + add_header Cache-Control "public"; + } +spec: + rules: + - host: ${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + http: + paths: + - pathType: Prefix + path: /()(.*) + backend: + service: + name: ${{ defaults.app_name }} + port: + number: 3000 + tls: + - hosts: + - ${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + secretName: ${{ SEALOS_CERT_SECRET_NAME }} +``` + +
+ +Please note that for security purposes, the `host` field needs to be set randomly. You can set `${{ random(8) }}` as `defaults.app_host`, and then use `${{ defaults.app_host }}`. + +### Explanation: `NodePort Type Service` + +If the application needs to expose services through a NodePort type Service, the following naming convention must be followed: the Service name should have `-nodeport` as a suffix. For example: + +
+ +Demo + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: ${{ defaults.app_name }}-nodeport + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }}-nodeport +spec: + type: NodePort + ports: + - protocol: UDP + port: 21116 + targetPort: 21116 + name: "rendezvous-udp" + - protocol: TCP + port: 21116 + targetPort: 21116 + name: "rendezvous-tcp" + - protocol: TCP + port: 21117 + targetPort: 21117 + name: "relay" + - protocol: TCP + port: 21115 + targetPort: 21115 + name: "heartbeat" + selector: + app: ${{ defaults.app_name }} +``` + +
+ +This naming convention (`${{ defaults.app_name }}-nodeport`) is required for NodePort type Services so that the system can correctly identify and handle this type of resource. + +### Explanation: `Underlying Dependencies` + +Almost all applications require underlying dependencies, such as `database`, `cache`, `object storage`, etc. You can add the following code to deploy some of the underlying dependencies we provide: + +#### `Database` + +We use [`kubeblocks`](https://kubeblocks.io/) to provide database resource support. You can directly use the following code to deploy databases: + +
+ +MongoDB + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + finalizers: + - cluster.kubeblocks.io/finalizer + labels: + kb.io/database: mongodb-8.0.4 + app.kubernetes.io/instance: ${{ defaults.app_name }}-mongo + annotations: {} + name: ${{ defaults.app_name }}-mongo + generation: 1 +spec: + affinity: + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: + - kubernetes.io/hostname + componentSpecs: + - componentDef: mongodb + name: mongodb + replicas: 1 + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + serviceAccountName: ${{ defaults.app_name }}-mongo + serviceVersion: 8.0.4 + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: openebs-backup + terminationPolicy: Delete + + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mongo + app.kubernetes.io/instance: ${{ defaults.app_name }}-mongo + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mongo + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mongo + app.kubernetes.io/instance: ${{ defaults.app_name }}-mongo + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mongo +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mongo + app.kubernetes.io/instance: ${{ defaults.app_name }}-mongo + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mongo +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${{ defaults.app_name }}-mongo +subjects: + - kind: ServiceAccount + name: ${{ defaults.app_name }}-mongo +``` + +
+ +
+ +PostgreSQL + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + finalizers: + - cluster.kubeblocks.io/finalizer + labels: + kb.io/database: postgresql-16.4.0 + clusterdefinition.kubeblocks.io/name: postgresql + clusterversion.kubeblocks.io/name: postgresql-16.4.0 + annotations: {} + name: ${{ defaults.app_name }}-pg +spec: + affinity: + nodeLabels: {} + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: [] + clusterDefinitionRef: postgresql + clusterVersionRef: postgresql-16.4.0 + componentSpecs: + - componentDefRef: postgresql + disableExporter: true + enabledLogs: + - running + name: postgresql + replicas: 1 + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + serviceAccountName: ${{ defaults.app_name }}-pg + switchPolicy: + type: Noop + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: openebs-backup + terminationPolicy: Delete + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-pg + app.kubernetes.io/instance: ${{ defaults.app_name }}-pg + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-pg + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-pg + app.kubernetes.io/instance: ${{ defaults.app_name }}-pg + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-pg +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-pg + app.kubernetes.io/instance: ${{ defaults.app_name }}-pg + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-pg +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${{ defaults.app_name }}-pg +subjects: + - kind: ServiceAccount + name: ${{ defaults.app_name }}-pg +``` + +
+ +
+ +MySQL + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + finalizers: + - cluster.kubeblocks.io/finalizer + labels: + kb.io/database: ac-mysql-8.0.30-1 + clusterdefinition.kubeblocks.io/name: apecloud-mysql + clusterversion.kubeblocks.io/name: ac-mysql-8.0.30-1 + annotations: {} + name: ${{ defaults.app_name }}-mysql +spec: + affinity: + nodeLabels: {} + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: [] + clusterDefinitionRef: apecloud-mysql + clusterVersionRef: ac-mysql-8.0.30-1 + componentSpecs: + - componentDefRef: mysql + monitor: true + name: mysql + noCreatePDB: false + replicas: 1 + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + serviceAccountName: ${{ defaults.app_name }}-mysql + switchPolicy: + type: Noop + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: openebs-backup + terminationPolicy: Delete + tolerations: [] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mysql + app.kubernetes.io/instance: ${{ defaults.app_name }}-mysql + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mysql + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mysql + app.kubernetes.io/instance: ${{ defaults.app_name }}-mysql + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mysql +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-mysql + app.kubernetes.io/instance: ${{ defaults.app_name }}-mysql + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-mysql +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${{ defaults.app_name }}-mysql +subjects: + - kind: ServiceAccount + name: ${{ defaults.app_name }}-mysql + +``` + +
+ +
+ +Redis + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + finalizers: + - cluster.kubeblocks.io/finalizer + labels: + kb.io/database: redis-7.2.7 + app.kubernetes.io/instance: ${{ defaults.app_name }}-redis + app.kubernetes.io/version: 7.2.7 + clusterversion.kubeblocks.io/name: redis-7.2.7 + clusterdefinition.kubeblocks.io/name: redis + annotations: {} + name: ${{ defaults.app_name }}-redis +spec: + affinity: + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: + - kubernetes.io/hostname + clusterDefinitionRef: redis + componentSpecs: + - componentDef: redis-7 + name: redis + replicas: 1 + enabledLogs: + - running + env: + - name: CUSTOM_SENTINEL_MASTER_NAME + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + serviceAccountName: ${{ defaults.app_name }}-redis + serviceVersion: 7.2.7 + switchPolicy: + type: Noop + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: openebs-backup + - componentDef: redis-sentinel-7 + name: redis-sentinel + replicas: 1 + resources: + limits: + cpu: 100m + memory: 100Mi + requests: + cpu: 10m + memory: 10Mi + serviceAccountName: ${{ defaults.app_name }}-redis + serviceVersion: 7.2.7 + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + terminationPolicy: Delete + topology: replication +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-redis + app.kubernetes.io/instance: ${{ defaults.app_name }}-redis + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-redis + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-redis + app.kubernetes.io/instance: ${{ defaults.app_name }}-redis + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-redis +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-redis + app.kubernetes.io/instance: ${{ defaults.app_name }}-redis + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-redis +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${{ defaults.app_name }}-redis +subjects: + - kind: ServiceAccount + name: ${{ defaults.app_name }}-redis +``` + +
+ +
+ +Kafka + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + labels: + kb.io/database: kafka-3.3.2 + clusterdefinition.kubeblocks.io/name: kafka + clusterversion.kubeblocks.io/name: kafka-3.3.2 + name: ${{ defaults.app_name }}-kafka + annotations: + kubeblocks.io/extra-env: >- + {"KB_KAFKA_ENABLE_SASL":"false","KB_KAFKA_BROKER_HEAP":"-XshowSettings:vm + -XX:MaxRAMPercentage=100 + -Ddepth=64","KB_KAFKA_CONTROLLER_HEAP":"-XshowSettings:vm + -XX:MaxRAMPercentage=100 -Ddepth=64","KB_KAFKA_PUBLIC_ACCESS":"false"} +spec: + terminationPolicy: Delete + componentSpecs: + - name: broker + componentDef: kafka-broker + tls: false + replicas: 1 + affinity: + podAntiAffinity: Preferred + topologyKeys: + - kubernetes.io/hostname + tenancy: SharedNode + tolerations: + - key: kb-data + operator: Equal + value: 'true' + effect: NoSchedule + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - name: metadata + spec: + storageClassName: null + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - name: controller + componentDefRef: controller + componentDef: kafka-controller + tls: false + replicas: 1 + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + volumeClaimTemplates: + - name: metadata + spec: + storageClassName: null + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - name: metrics-exp + componentDef: kafka-exporter + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi +``` + +
+ +
+ +Milvus + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + labels: + clusterdefinition.kubeblocks.io/name: milvus + name: ${{ defaults.app_name }}-milvus +spec: + affinity: + podAntiAffinity: Preferred + tenancy: SharedNode + clusterDefinitionRef: milvus + clusterVersionRef: milvus-2.2.4 + terminationPolicy: Delete + componentSpecs: + - componentDefRef: milvus + name: milvus + disableExporter: true + serviceAccountName: ${{ defaults.app_name }}-milvus + replicas: 1 + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - componentDefRef: etcd + name: etcd + disableExporter: true + serviceAccountName: ${{ defaults.app_name }}-milvus + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - componentDefRef: minio + name: minio + disableExporter: true + serviceAccountName: ${{ defaults.app_name }}-milvus + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + resources: + cpu: '0' + memory: '0' + storage: + size: '0' +``` + +
+ +
+ +ClickHouse + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + labels: + kb.io/database: clickhouse-24.8.3 + clusterdefinition.kubeblocks.io/name: clickhouse + clusterversion.kubeblocks.io/name: clickhouse-24.8.3 + name: ${{ defaults.app_name }}-clickhouse +spec: + affinity: + podAntiAffinity: Preferred + tenancy: SharedNode + topologyKeys: + - cluster + clusterDefinitionRef: clickhouse + componentSpecs: + - componentDefRef: zookeeper + disableExporter: true + name: zookeeper + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + serviceAccountName: ${{ defaults.app_name }}-clickhouse + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - componentDefRef: clickhouse + disableExporter: true + name: clickhouse + replicas: 1 + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + serviceAccountName: ${{ defaults.app_name }}-clickhouse + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + - componentDefRef: ch-keeper + disableExporter: true + name: ch-keeper + replicas: 1 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 50m + memory: 51Mi + serviceAccountName: ${{ defaults.app_name }}-clickhouse + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + terminationPolicy: Delete +``` + +
+ +
+ +Weaviate + +```yaml +apiVersion: apps.kubeblocks.io/v1alpha1 +kind: Cluster +metadata: + finalizers: + - cluster.kubeblocks.io/finalizer + labels: + clusterdefinition.kubeblocks.io/name: weaviate + clusterversion.kubeblocks.io/name: weaviate-1.18.0 + name: ${{ defaults.app_name }}-weaviate +spec: + affinity: + podAntiAffinity: Preferred + tenancy: SharedNode + clusterDefinitionRef: weaviate + clusterVersionRef: weaviate-1.18.0 + componentSpecs: + - componentDefRef: weaviate + monitor: false + name: weaviate + noCreatePDB: false + replicas: 1 + resources: + limits: + cpu: "1" + memory: 1Gi + requests: + cpu: "1" + memory: 1Gi + rsmTransformPolicy: ToSts + serviceAccountName: ${{ defaults.app_name }}-weaviate + volumeClaimTemplates: + - name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + monitor: {} + resources: + cpu: "0" + memory: "0" + storage: + size: "0" + terminationPolicy: Delete +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-weaviate + app.kubernetes.io/instance: ${{ defaults.app_name }}-weaviate + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-weaviate + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-weaviate + app.kubernetes.io/instance: ${{ defaults.app_name }}-weaviate + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-weaviate +rules: + - apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-weaviate + app.kubernetes.io/instance: ${{ defaults.app_name }}-weaviate + app.kubernetes.io/managed-by: kbcli + name: ${{ defaults.app_name }}-weaviate +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${{ defaults.app_name }}-weaviate +subjects: + - kind: ServiceAccount + name: ${{ defaults.app_name }}-weaviate +``` + +
+ +When deploying a database, you only need to focus on the resources used by the database: + +| Field | Description | +| ----------- | --------------- | +| `replicas` | Number of instances | +| `resources` | Allocate CPU and memory | +| `storage` | Volume size | + +#### How to Access the Application's Database + +The database username/password is set as a secret for future use. It can be added to environment variables through the following code. Once added, you can read the MONGODB password in the container via $(MONGO_PASSWORD). + +```yaml +... +spec: + containers: + - name: ${{ defaults.app_name }} + ... + env: + - name: MONGO_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-mongodb-account-root + key: password +... +``` + +#### `Object Storage` + +We use object storage to provide bucket resource support. You can directly use the following code to deploy a bucket: + +```yaml +apiVersion: objectstorage.sealos.io/v1 +kind: ObjectStorageBucket +metadata: + name: ${{ defaults.app_name }} +spec: + policy: private +``` + +The policy has three types: private (private bucket, not open), publicRead (shared bucket, open for public read), and publicReadwrite (shared bucket, open for public read and write). + +#### How to Access the Application's Bucket + +The bucket access key and access address are stored in a secret. They can be added to environment variables through the following code. + +```yaml +... +spec: + containers: + - name: ${{ defaults.app_name }} + ... + env: + - name: ACCESS_KEY + valueFrom: + secretKeyRef: + name: object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }} + key: accessKey + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }} + key: secretKey + - name: EXTERNAL_ENDPOINT + valueFrom: + secretKeyRef: + name: object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }} + key: external + - name: INTERNAL_ENDPOINT + valueFrom: + secretKeyRef: + name: object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }} + key: internal + - name: BUCKET_NAME + valueFrom: + secretKeyRef: + name: object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }} + key: bucket +... +``` + +### Note: System Underlying Processing Logic + +#### Template Instance + +To facilitate user management and modification of applications deployed through templates, the system deploys an `app.sealos.io/v1, Kind=Instance` CRD as the application instance during actual deployment. + +The CRD itself will be fully migrated according to the template format and fields of `app.sealos.io/v1, Kind=Template`, with the following processing logic: + +1. Replace all variables/functions in the template with definite values +2. Change the kind from `Template` to `Instance` +3. Apply this template instance to the user's namespace + +#### Resource Labels + +For all resources deployed through the template marketplace, including system resources such as `deploy`, `service` as well as custom resources such as `app`, `kb database`, etc., a unified label will be added to all of them: `cloud.sealos.io/deploy-on-sealos: $app_name`. + +Where `app_name` is the name of the application deployed by the user, which by default ends with a random number, such as `fastgpt-zu1n048s`. + +## Part 3: `Rendering Process Details` + +The Sealos template engine follows a specific order during the rendering process to ensure that variables and conditional statements can be correctly parsed. + +
+ +The following flowchart details the entire rendering process + +```mermaid +graph TB + subgraph father[ ] + style A fill:#FFD700,stroke:#333,stroke-width:2px,color:#000 + style B fill:#1E90FF,stroke:#333,stroke-width:2px,color:#FFF + style C fill:#1E90FF,stroke:#333,stroke-width:2px,color:#FFF + style D fill:#FF6347,stroke:#333,stroke-width:2px,color:#FFF + style E fill:#FFD700,stroke:#333,stroke-width:2px,color:#000 + style F fill:#1E90FF,stroke:#333,stroke-width:2px,color:#FFF + style G fill:#1E90FF,stroke:#333,stroke-width:2px,color:#FFF + style H fill:#FF6347,stroke:#333,stroke-width:2px,color:#FFF + style I fill:#FFD700,stroke:#333,stroke-width:2px,color:#000 + style J fill:#1E90FF,stroke:#333,stroke-width:2px,color:#FFF + style K fill:#1E90FF,stroke:#333,stroke-width:2px,color:#FFF + style L fill:#FF6347,stroke:#333,stroke-width:2px,color:#FFF + + subgraph sub1[ ] + A[1. Get Template CR file] --> B[Parse defaults] + B -- Only built-in system variables and functions allowed --> C[Parse inputs] + C -- Built-in system variables, functions, and defaults allowed --> D[Template CR parsing complete] + end + subgraph sub2[ ] + E[2. Parse application resource files] --> F[Conditional rendering] + F -- Selectively render code blocks based on expression truth values --> G[Variable resolution] + G -- Replace placeholders using defaults, inputs, and built-in variables/functions --> H[Application resource file parsing complete] + end + subgraph sub3[ ] + I[3. Render Form and YAML file list] --> J[Form conditional rendering] + J -- Selectively render form items based on expression truth values --> K[Form changes trigger re-rendering] + K -- Re-execute step 2 --> L[Rendering complete] + end + + sub1 --> sub2 + sub2 --> sub3 + end +``` + +
+ +- Parse Template CR + - First, the system reads the `Template CR` file. + - Then, it parses the `spec.defaults` field, which defines the default values for the template. + - In the `defaults` field, only predefined [built-in system variables](#built-in-system-variables) and [built-in system functions](#built-in-system-functions) are allowed. + - Next, it parses the `spec.inputs` field, which defines the parameters that users need to fill in. + - In the `inputs` field, in addition to built-in system variables and functions, variables defined in `defaults` can also be referenced. +- Parse application resource files + - At this stage, expressions can reference `built-in system variables`, `built-in system functions`, as well as `defaults` and `inputs`. + - First, [conditional rendering](#conditional-rendering) is performed, selectively rendering code blocks based on the truth values of conditional expressions. + - Then, [variable resolution](#built-in-system-variables) is performed, replacing placeholders in resource files using `defaults`, `inputs`, and built-in variables/functions. +- Render Form and YAML file list + - Finally, the system renders the Form based on the parsed `inputs` field, where users can fill in custom parameters. + - At this stage, expressions can reference `built-in system variables`, `built-in system functions`, as well as `defaults` and `inputs`. + - When the `Form` changes, it triggers re-rendering of the `YAML` file list. + +> Note: +> +> When users enter information in input fields, the `Template CR` content will not be re-parsed, +> meaning the original expressions will not be re-evaluated, such as `value: ${{ random(8) }}`. diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/references/frappe-bench.md b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/frappe-bench.md new file mode 100644 index 00000000..7b310406 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/frappe-bench.md @@ -0,0 +1,34 @@ +# Frappe / Bench Conversion Notes + +Use this reference when a project mentions Frappe, ERPNext, HRMS, `bench`, `frappe/bench`, `frappe_docker`, or commands such as `bench new-site`, `bench start`, `bench set-mariadb-host`, or `bench set-redis-*`. + +## Runtime Model + +Frappe repositories are often app modules, not standalone web servers. Treat `docker/` compose files and init scripts as authoritative startup evidence. + +Two common models: + +- **Development bench model**: `frappe/bench:*` runs `bench init`, `bench get-app`, `bench new-site`, `bench install-app`, then `bench start`. +- **Prebuilt production image model**: `frappe/erpnext:*` or `ghcr.io/frappe/:` already contains apps and normally uses separate web, frontend, websocket, worker, scheduler processes. + +Do not mix these models blindly. If a repo's `docker/docker-compose.yml` uses `frappe/bench:latest` plus a mounted `init.sh`, do not assume a detected prebuilt GHCR image has the same startup contract. + +## Template Rules + +- Database services from Compose (`mariadb`, `mysql`, `redis`) should follow the Sealos database strategy. Redis must use the KubeBlocks Redis `Cluster` and its generated Secret (`${{ defaults.app_name }}-redis-redis-account-default`) unless the user explicitly asks for raw containers. +- If using a prebuilt Frappe image with mounted `sites` and `logs` PVCs, set pod `securityContext.fsGroup: 1000` so the `frappe` user can write mounted volumes. +- Init containers that run `bench init`, `bench new-site`, `bench migrate`, or app install steps must set explicit resources. Do not rely on namespace defaults; `64Mi` memory is too small. Use at least: + - light config init: `requests.memory: 128Mi`, `limits.memory: 256Mi` + - `bench new-site` / app install / migrate: `requests.memory: 512Mi`, `limits.memory: 2Gi` +- Bootstrap scripts must be idempotent and recover from partial initialization: + - create `sites/common_site_config.json` if a fresh PVC hides the image's bundled file + - use `bench new-site --force` for first-site creation when the database may contain residue from a previous failed attempt + - check both filesystem site state and database readiness; a `sites/` directory alone does not prove the Frappe database is valid +- Prefer the source docs' site name when it is part of the documented flow (`hrms.localhost` in development docs). For public Sealos access, ensure the frontend's site-name/header behavior matches the generated Ingress host or an intentionally configured default site. + +## Failure Signatures + +- `Permission denied` writing `sites/apps.txt`: mounted PVC ownership is wrong; add `fsGroup: 1000` or a volume-permission init. +- `OOMKilled` / exit `137` in `create-site`: init resources are too small. +- `pymysql.err.ProgrammingError: ('DocType', 'Patch Log')`: a prior failed init left a site directory or database residue; reset the failed site/database or rerun `bench new-site --force`. +- Ingress returns `no healthy upstream`: usually not an Ingress problem; check Service endpoints, Pod readiness, and init container state first. diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/references/must-rules-map.yaml b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/must-rules-map.yaml new file mode 100644 index 00000000..72c17ffa --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/must-rules-map.yaml @@ -0,0 +1,282 @@ +version: 1 +must_rules: + - must: "Template `metadata.name` must be hardcoded lowercase; do not use `${{ defaults.app_name }}`." + enforcement: + type: rule + target: R004 + - must: "Template CR folder name must match `metadata.name`." + enforcement: + type: rule + target: R013 + - must: "Template CR must include required metadata fields (`title`, `url`, `gitRepo`, `author`, `description`, `icon`, `templateType`, `locale`, `i18n`, `categories`)." + enforcement: + type: rule + target: R012 + - must: "Template `spec.readme` must point to `https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template//README.md`." + enforcement: + type: rule + target: R025 + - must: "Template `spec.i18n.zh.readme` must point to `https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template//README_zh.md`." + enforcement: + type: rule + target: R025 + - must: "These README fields are URL references in `index.yaml` only; this skill must not create or update the referenced README files." + enforcement: + type: manual + note: "Review file changes to ensure README files were not created or updated by this skill." + - must: "`icon` URL must point to template repo raw path for this app on `kb-0.9` branch." + enforcement: + type: rule + target: R014 + - must: "`template//logo.` must use square/circular icon-first artwork (for example app icon/favicon/avatar), and must not use rectangular wordmark/text logos." + enforcement: + type: manual + note: "Review selected logo asset shape/content and reject rectangular wordmark/text logos." + - must: "`i18n.zh.description` must be written in Simplified Chinese." + enforcement: + type: rule + target: R021 + - must: "Omit `i18n.zh.title` when it is identical to `title`." + enforcement: + type: rule + target: R022 + - must: "`categories` must only use predefined values (`tool`, `ai`, `game`, `database`, `low-code`, `monitor`, `dev-ops`, `blog`, `storage`, `frontend`, `backend`)." + enforcement: + type: rule + target: R023 + - must: "App resource must use `spec.data.url`." + enforcement: + type: rule + target: R003 + - must: "App resource `spec.displayType` must be `normal`." + enforcement: + type: rule + target: R032 + - must: "App resource `spec.type` must be `link`." + enforcement: + type: rule + target: R033 + - must: "Never use `spec.template` in App resource." + enforcement: + type: rule + target: R002 + - must: "`cloud.sealos.io/app-deploy-manager` label value must equal resource `metadata.name`." + enforcement: + type: rule + target: R008 + - must: "`metadata.labels.app` label value must equal resource `metadata.name` for managed app workloads." + enforcement: + type: rule + target: R034 + - must: "`containers[*].name` must equal workload `metadata.name` for managed app workloads." + enforcement: + type: rule + target: R028 + - must: "Application `Service` resources must define `metadata.labels.app` and `metadata.labels.cloud.sealos.io/app-deploy-manager`, and both labels must match `spec.selector.app`." + enforcement: + type: rule + target: R029 + - must: "Component-scoped `ConfigMap` resources must define `metadata.labels.app` and `metadata.labels.cloud.sealos.io/app-deploy-manager`, and both labels must match `metadata.name`." + enforcement: + type: rule + target: R030 + - must: "Application `Service` resources must use the same component name across `metadata.name`, `metadata.labels.app`, `metadata.labels.cloud.sealos.io/app-deploy-manager`, and `spec.selector.app`." + enforcement: + type: rule + target: R029 + - must: "Application `Ingress` resources must use the same component name across `metadata.name`, `metadata.labels.cloud.sealos.io/app-deploy-manager`, and backend `service.name`." + enforcement: + type: rule + target: R031 + - must: "Service `spec.ports[*].name` must be explicitly set (required for multi-port services)." + enforcement: + type: rule + target: R020 + - must: "HTTP Ingress must include required nginx annotations (`kubernetes.io/ingress.class`, `nginx.ingress.kubernetes.io/proxy-body-size`, `nginx.ingress.kubernetes.io/server-snippet`, `nginx.ingress.kubernetes.io/ssl-redirect`, `nginx.ingress.kubernetes.io/backend-protocol`, `nginx.ingress.kubernetes.io/client-body-buffer-size`, `nginx.ingress.kubernetes.io/proxy-buffer-size`, `nginx.ingress.kubernetes.io/proxy-send-timeout`, `nginx.ingress.kubernetes.io/proxy-read-timeout`, `nginx.ingress.kubernetes.io/configuration-snippet`) with expected defaults." + enforcement: + type: rule + target: R026 + - must: "CronJob resources must define labels `cloud.sealos.io/cronjob`, `cronjob-launchpad-name`, and `cronjob-type`; `cloud.sealos.io/cronjob` must equal `metadata.name`, `cronjob-launchpad-name` must be `\"\"`, and `cronjob-type` must be `image`." + enforcement: + type: rule + target: R036 + - must: "When official application health checks are available, managed workloads must define `livenessProbe`, `readinessProbe`, and (for slow bootstrap apps) `startupProbe`, aligned with official endpoints/commands." + enforcement: + type: rule + target: R024 + - must: "If official Kubernetes installation docs/manifests are available, conversion must reference them and align critical runtime settings before emitting template artifacts." + enforcement: + type: manual + note: "Requires cross-source analysis between official Kubernetes docs/manifests and Compose inputs." + - must: "When official Kubernetes docs/manifests and Compose differ, prefer official Kubernetes runtime semantics for app behavior (bootstrap admin fields, external endpoint/env/protocol, health probes), unless doing so violates higher-priority Sealos MUST/security constraints." + enforcement: + type: manual + note: "Conflict resolution depends on semantic comparison plus platform/security guardrails." + - must: "Do not use `:latest`." + enforcement: + type: rule + target: R001 + - must: "Resolve versions with `crane`: prefer an explicit version tag (for example `v2.2.0`), and fallback to digest pin only when a deterministic version tag is unavailable." + enforcement: + type: manual + note: "Requires live image resolution behavior, not derivable from static snippets alone." + - must: "Avoid floating tags (for example `:v2`, `:2.1`, `:stable`); use an explicit version tag or digest." + enforcement: + type: rule + target: R016 + - must: "Managed workload image references must be concrete and must not contain Compose-style variable expressions (for example `${VAR}`, `${VAR:-default}`); resolve to explicit tag or digest before emitting template artifacts." + enforcement: + type: rule + target: R018 + - must: "Application `originImageName` must match container image." + enforcement: + type: rule + target: R015 + - must: "Managed app workloads must reference the app-scoped image pull Secret `${{ defaults.app_name }}` via `template.spec.imagePullSecrets`." + enforcement: + type: rule + target: R035 + - must: "The registry pull Secret is runtime-managed by `sealos-deploy` using local `gh` CLI credentials for private GHCR images; do not expose raw registry credential inputs in generated templates." + enforcement: + type: manual + note: "Requires deploy-path behavior that cannot be validated from static template snippets alone." + - must: "All containers must explicitly set `imagePullPolicy: IfNotPresent`." + enforcement: + type: rule + target: R006 + - must: "Do not use `emptyDir`." + enforcement: + type: rule + target: R005 + - must: "Use persistent storage patterns (`volumeClaimTemplates`) where storage is needed." + enforcement: + type: manual + note: "Requires intent-level storage-need inference for each service." + - must: "PVC request must be `<= 1Gi` unless source spec explicitly requires less." + enforcement: + type: rule + target: R011 + - must: "ConfigMap keys and volume names must follow vn naming (`scripts/path_converter.py`)." + enforcement: + type: manual + note: "Needs deterministic vn-name mapping checks against path semantics." + - must: "Non-database sensitive values/inputs use direct `env[].value`." + enforcement: + type: manual + note: "Sensitive-value classification requires semantic analysis of env names and source intent." + - must: "Business containers must source database connection fields (`endpoint`, `host`, `port`, `username`, `password`) from approved Kubeblocks database secrets via `env[].valueFrom.secretKeyRef`; exception: Redis `host`/`port` may use Sealos Redis Service FQDN and `6379` when the Redis secret only exposes credentials, and MongoDB connection URLs may use the Sealos MongoDB Service FQDN plus `27017` when the MongoDB secret exposes credentials only." + enforcement: + type: rule + target: R017 + - must: "Business containers must not use custom env/volume `Secret` references except approved Kubeblocks database secrets and object storage secrets." + enforcement: + type: rule + target: R007 + - must: "A dedicated app-scoped registry pull Secret is allowed and should be referenced only through `template.spec.imagePullSecrets`." + enforcement: + type: rule + target: R035 + - must: "Database connection/bootstrap may use Kubeblocks-provided secrets, and reserved Kubeblocks database secret names must not be redefined by custom `Secret` resources." + enforcement: + type: rule + target: R007 + - must: "Env vars must be declared before referenced (for example password before URL composition)." + enforcement: + type: manual + note: "Requires expression dependency graph over env variable interpolation order." + - must: "Follow official app env var naming; do not invent prefixes." + enforcement: + type: manual + note: "Requires per-application canonical env dictionary from upstream docs." + - must: "When the application requires its public URL configured via a file-based config system (e.g., node-config `config/default.json`, PHP config files), create a ConfigMap containing the config file with the public URL set to `https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}`, and mount it to the application's config directory. The ConfigMap must follow standard naming and label conventions." + enforcement: + type: manual + note: "Requires runtime-config detection plus path-aware ConfigMap mount validation." + - must: "For PostgreSQL custom databases (non-`postgres`), include `${{ defaults.app_name }}-pg-init` Job and implement startup-safe/idempotent creation logic (readiness wait + existence check before create)." + enforcement: + type: rule + target: R027 + - must: "PostgreSQL version: `postgresql-16.4.0`." + enforcement: + type: manual + note: "Needs database-template semantic checks across generated resources." + - must: "PostgreSQL API: `apps.kubeblocks.io/v1alpha1`." + enforcement: + type: manual + note: "Needs Cluster-kind/apiVersion validation in generated manifests." + - must: "PostgreSQL RBAC unified naming: `${{ defaults.app_name }}-pg`." + enforcement: + type: manual + note: "Requires coordinated naming validation across SA/Role/RoleBinding/Cluster." + - must: "PostgreSQL RBAC requires `app.kubernetes.io/instance` and `app.kubernetes.io/managed-by` labels." + enforcement: + type: manual + note: "Requires label presence checks scoped to PostgreSQL RBAC resources." + - must: "PostgreSQL role wildcard permission requirement remains as defined in current spec." + enforcement: + type: manual + note: "Requires policy-level RBAC rule validation tied to current platform spec." + - must: "PostgreSQL cluster must include required labels/fields (`kb.io/database: postgresql-16.4.0`, `clusterdefinition.kubeblocks.io/name: postgresql`, `clusterversion.kubeblocks.io/name: postgresql-16.4.0`, `clusterVersionRef: postgresql-16.4.0`, `disableExporter: true`, `enabledLogs: [running]`, `switchPolicy.type: Noop`, `serviceAccountName`)." + enforcement: + type: manual + note: "Requires deep PostgreSQL Cluster schema validation." + - must: "MongoDB cluster must follow upgraded structure (`componentDef: mongodb`, `serviceVersion: 8.0.4`, labels `kb.io/database` and `app.kubernetes.io/instance`)." + enforcement: + type: manual + note: "Requires schema-level validation for MongoDB cluster fields." + - must: "MySQL cluster must follow upgraded structure (`kb.io/database: ac-mysql-8.0.30-1`, `clusterDefinitionRef: apecloud-mysql`, `clusterVersionRef: ac-mysql-8.0.30-1`, `tolerations: []`)." + enforcement: + type: manual + note: "Requires schema-level validation for MySQL cluster fields." + - must: "Redis cluster must follow upgraded structure (`componentDef: redis-7`, `componentDef: redis-sentinel-7`, `serviceVersion: 7.2.7`, main data PVC `1Gi`, topology `replication`)." + enforcement: + type: manual + note: "Requires schema-level validation for Redis cluster fields." + - must: "Database cluster component resources must use `limits(cpu=500m,memory=512Mi)` and `requests(cpu=50m,memory=51Mi)` unless source docs explicitly require otherwise." + enforcement: + type: rule + target: R019 + - must: "MongoDB: `${{ defaults.app_name }}-mongo-mongodb-account-root` (or `${{ defaults.app_name }}-mongodb-mongodb-account-root` when the MongoDB cluster name uses `-mongodb`)" + enforcement: + type: rule + target: R007 + - must: "Redis: `${{ defaults.app_name }}-redis-redis-account-default` (legacy `${{ defaults.app_name }}-redis-account-default` may be accepted for backward compatibility)" + enforcement: + type: rule + target: R007 + - must: "Kafka: `${{ defaults.app_name }}-broker-account-admin`" + enforcement: + type: rule + target: R007 + - must: "Do not use legacy naming outside supported exceptions." + enforcement: + type: rule + target: R007 + - must: "container limits: `cpu=200m`, `memory=256Mi`" + enforcement: + type: manual + note: "Requires runtime-default validation with source-doc override awareness." + - must: "container requests: `cpu=20m`, `memory=25Mi`" + enforcement: + type: manual + note: "Requires runtime-default validation with source-doc override awareness." + - must: "`revisionHistoryLimit: 1`" + enforcement: + type: rule + target: R009 + - must: "`automountServiceAccountToken: false`" + enforcement: + type: rule + target: R010 + - must: "`defaults` for generated values (`app_name`, `app_host`, random passwords/keys)." + enforcement: + type: manual + note: "Needs cross-section analysis of defaults/inputs usage semantics." + - must: "`inputs` only for truly user-provided operational values (email/SMTP/external API keys, etc.)." + enforcement: + type: manual + note: "Requires semantic classification of user-supplied versus generated values." + - must: "`inputs.description` must be in English." + enforcement: + type: manual + note: "Requires language detection over input description fields in generated templates." diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/references/rules-registry.yaml b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/rules-registry.yaml new file mode 100644 index 00000000..2628fc02 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/rules-registry.yaml @@ -0,0 +1,120 @@ +version: 1 +scope: + include: + - SKILL.md + - references/sealos-specs.md + - references/conversion-mappings.md + - references/database-templates.md + - references/example-guide.md +rules: + - id: R001 + description: Disallow :latest tags in image/originImageName fields. + severity: error + - id: R016 + description: Disallow floating image tags (for example v2, 2.1, stable) on managed app workloads. + severity: error + - id: R018 + description: Disallow Compose-style variable expressions in managed workload image/originImageName fields. + severity: error + - id: R002 + description: App resources must not use spec.template. + severity: error + - id: R003 + description: App resources must define spec.data.url. + severity: error + - id: R032 + description: App resources must define spec.displayType and it must be normal. + severity: error + - id: R033 + description: App resources must define spec.type and it must be link. + severity: error + - id: R004 + description: Template metadata.name must be hardcoded lowercase. + severity: error + - id: R012 + description: Template artifacts must define all required metadata fields in spec. + severity: error + - id: R013 + description: Template artifact folder name must match Template metadata.name. + severity: error + - id: R014 + description: Template spec.icon must point to app-scoped raw template paths on kb-0.9 branch. + severity: error + - id: R025 + description: Template spec.readme and spec.i18n.zh.readme must point to fixed labring-actions template raw URLs. + severity: error + - id: R021 + description: Template spec.i18n.zh.description must be provided in Simplified Chinese. + severity: error + - id: R022 + description: Template spec.i18n.zh.title should be omitted when it is identical to spec.title. + severity: error + - id: R023 + description: Template spec.categories entries must use predefined allowlist values. + severity: error + - id: R024 + description: Workloads with official health checks must define livenessProbe/readinessProbe and startupProbe using official endpoints/commands. + severity: error + - id: R036 + description: CronJob resources must define cloud.sealos.io/cronjob, cronjob-launchpad-name, and cronjob-type labels with required values. + severity: error + - id: R015 + description: Managed app workload originImageName must match a declared container image. + severity: error + - id: R005 + description: emptyDir is forbidden; use persistent storage. + severity: error + - id: R006 + description: All containers must explicitly set imagePullPolicy to IfNotPresent. + severity: error + - id: R007 + description: Env/volume Secret usage must follow approved database/object-storage policy, including database secret naming variants and bucket-scoped object-storage secrets, and Kubeblocks reserved database secret names must not be overridden. + severity: error + - id: R017 + description: Database connection env fields (endpoint/host/port/username/password) in business workloads must use approved Kubeblocks database secretKeyRef entries, except Redis host/port may use Sealos Redis Service FQDN and port 6379, MongoDB URLs may use Sealos MongoDB Service FQDN and port 27017, and endpoint/URL fields may compose values from approved DB secretKeyRef env vars. + severity: error + - id: R019 + description: Database cluster component resources must use limits cpu=500m/memory=512Mi and requests cpu=50m/memory=51Mi. + severity: error + - id: R020 + description: Service spec.ports entries must define non-empty name fields in template artifacts. + severity: error + - id: R026 + description: HTTP Ingress resources in template artifacts must include the required nginx annotations with expected values. + severity: error + - id: R027 + description: Template artifacts using non-default PostgreSQL database names must define a robust pg-init Job (readiness wait plus idempotent create). + severity: error + - id: R037 + description: PostgreSQL Cluster metadata.name must align with referenced *-pg-conn-credential secret names in the same template artifact. + severity: error + - id: R008 + description: App workload cloud.sealos.io/app-deploy-manager label must exist and match metadata.name. + severity: error + - id: R034 + description: Managed app workloads must define metadata.labels.app and match metadata.name. + severity: error + - id: R028 + description: Managed app workload container names must exactly match metadata.name. + severity: error + - id: R029 + description: Application Services must use one component name across metadata.name, labels.app, cloud.sealos.io/app-deploy-manager, and spec.selector.app. + severity: error + - id: R030 + description: Component ConfigMaps must use one component name across metadata.name, labels.app, and cloud.sealos.io/app-deploy-manager. + severity: error + - id: R031 + description: Application Ingress resources must use one component name across metadata.name, cloud.sealos.io/app-deploy-manager, and backend service names. + severity: error + - id: R009 + description: Managed app workloads must explicitly set revisionHistoryLimit to 1. + severity: error + - id: R010 + description: Managed app workloads must explicitly set automountServiceAccountToken to false. + severity: error + - id: R035 + description: Managed app workloads must reference the app-scoped image pull secret name via template.spec.imagePullSecrets. + severity: error + - id: R011 + description: All PVC storage requests must be concrete values and less than or equal to 1Gi. + severity: error diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/references/sealos-specs.md b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/sealos-specs.md new file mode 100644 index 00000000..f61a6213 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/references/sealos-specs.md @@ -0,0 +1,1069 @@ +# Sealos Template Development Specification + +## Template File Organization Specification + +### Directory Structure Requirements + +All templates must be organized according to the following directory structure: + +``` +templates/ +└── template/ + └── / # The folder name must match the template's name field + └── index.yaml # The template file must be named index.yaml +``` + +### Example + +``` +templates/ +└── template/ + ├── formbricks/ + │ └── index.yaml # formbricks template file + ├── langflow/ + │ └── index.yaml # langflow template file + └── fastgpt/ + └── index.yaml # fastgpt template file +``` + +### Naming Rules + +1. The folder name must be consistent with the `metadata.name` field in the Template CR +2. The template file must be named `index.yaml` +3. Folder names should use lowercase letters and hyphens; avoid underscores or other special characters +4. **The `metadata.name` of the Template CR must be hardcoded in lowercase letters** and cannot use variables (such as `${{ defaults.app_name }}`) + +### Example + +```yaml +# Correct example +apiVersion: app.sealos.io/v1 +kind: Template +metadata: + name: typesense # ✅ Hardcoded lowercase name +spec: + defaults: + app_name: + type: string + value: typesense-${{ random(8) }} # ✅ Variables can be used here + +# Incorrect example +metadata: + name: ${{ defaults.app_name }} # ❌ Error: Variables cannot be used +``` + +## Resource Creation Order Specification + +Resources within a template must be created in the following order: + +### 1. Template CR +Create the Template metadata definition first + +### 2. Object Storage +```yaml +apiVersion: objectstorage.sealos.io/v1 +kind: ObjectStorageBucket +``` + +### 3. Database Resources +Database resources must be created in the following order: +1. **ServiceAccount** +2. **Role** +3. **RoleBinding** +4. **Cluster** (the actual database instance) +5. **Job** (if database initialization is needed) + +### 4. Application Resources +Application resources must be created in the following order: +1. **ConfigMap** (application configuration files) +2. **Deployment/StatefulSet** (main application) +3. **Service** +4. **Ingress** +5. **App** + +### Example Structure +``` +Template CR +--- +ObjectStorageBucket +--- +Redis ServiceAccount +--- +Redis Role +--- +Redis RoleBinding +--- +Redis Cluster +--- +PostgreSQL ServiceAccount +--- +PostgreSQL Role +--- +PostgreSQL RoleBinding +--- +PostgreSQL Cluster +--- +PostgreSQL Init Job +--- +Application StatefulSet +--- +Application Service +--- +Application Ingress +--- +App +``` + +### App CRD Specification (Important!) + +The `App` resource is the **last** resource in the template. It provides the Sealos dashboard entry point for the deployed application. + +**Complete and definitive schema — only these fields are valid:** + +```yaml +apiVersion: app.sealos.io/v1 +kind: App +metadata: + name: ${{ defaults.app_name }} + labels: + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +spec: + data: + url: https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + displayType: normal + icon: + name: + type: link +``` + +**Allowed `spec` fields (exhaustive list):** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `data.url` | string | **yes** | App access URL (Ingress host) | +| `displayType` | string | no | Display mode (`normal`) | +| `icon` | string | no | App icon URL | +| `name` | string | no | Human-readable title shown in dashboard | +| `type` | string | no | Entry type (`link`) | + +**When generating templates in this repository, treat these enum values as fixed:** `displayType` must be `normal`, and `type` must be `link`. Do not emit alternatives such as `standalone` or `web`. + +**Do NOT add any other fields.** Fields like `menuData`, `nameColor`, `template`, etc. do not exist in the App CRD and will cause `strict decoding error: unknown field` on apply. + +## Defaults and Inputs Configuration Specification + +### Basic Principles + +**Important distinction:** +- `defaults`: Used to store **automatically generated** values (such as random strings, random ports, etc.) +- `inputs`: Used to store values that **require user input** (such as email, API Key, custom configurations, etc.) + +### Defaults Configuration + +Values in `defaults` are automatically generated when the template is parsed and do not require user interaction: + +```yaml +defaults: + app_host: + type: string + value: typesense-${{ random(8) }} # ✅ With application name prefix + app_name: + type: string + value: typesense-${{ random(8) }} # ✅ Application name + api_key: + type: string + value: ${{ random(32) }} # ✅ Randomly generated secret key +``` + +**Notes:** +1. `app_host` must include an application name prefix (e.g., `typesense-${{ random(8) }}`) +2. `app_name` must include `${{ random(8) }}` to ensure uniqueness +3. Randomly generated configurations (secret keys, passwords, etc.) should be placed in `defaults`, not in `inputs` + +### Inputs Configuration + +Values in `inputs` need to be filled in by the user at deployment time: + +```yaml +inputs: + admin_email: + description: 'Administrator email address' + type: string + default: '' + required: true + enable_feature_x: + description: 'Enable advanced feature X' + type: boolean + default: 'false' + required: false +``` + +**When to use inputs:** +- ✅ User's email address +- ✅ Custom domain name +- ✅ API Key for external services (needs to be provided by the user) +- ✅ Feature toggles (enable/disable certain features) +- ❌ Randomly generated secret keys (should be placed in defaults) +- ❌ Automatically generated configurations (should be placed in defaults) + +## Internationalization (i18n) Configuration + +### Basic Format + +Templates need to add `locale` and `i18n` configuration to support multiple languages: + +```yaml +spec: + locale: en # Default language + i18n: + zh: + description: '中文描述' +``` + +### Configuration Example + +```yaml +apiVersion: app.sealos.io/v1 +kind: Template +metadata: + name: example +spec: + title: 'Example App' + description: 'An example application for demonstration' + locale: en + i18n: + zh: + description: '一个用于演示的示例应用程序' +``` + +### Supported Fields + +The i18n configuration supports translation of the following fields: +- `description` - Application description + +### Notes + +1. `locale` specifies the default language, typically set to `en` +2. Currently only `zh` (Chinese) translation is supported +3. `i18n.zh.description` should use Simplified Chinese +4. Technical field names and default values do not need translation +5. If the Chinese title is the same as `spec.title`, it is recommended to omit `i18n.zh.title` + +## Categories Restrictions + +When creating Sealos templates, the `categories` field cannot be customized and must be selected from the following predefined options: + +- `tool` - Utility applications +- `ai` - AI/Machine Learning related applications +- `game` - Game applications +- `database` - Database applications +- `low-code` - Low-code platforms +- `monitor` - Monitoring applications +- `dev-ops` - DevOps tools +- `blog` - Blog/Content management systems +- `storage` - Storage applications +- `frontend` - Frontend applications +- `backend` - Backend applications + +### Example +```yaml +categories: + - storage # Correct: Using a predefined category + - tool # Correct: Multiple categories can be selected + # - media # Error: Not in the allowed list +``` + +## Storage Specification + +### emptyDir Restriction (Important!) + +**Sealos does not support emptyDir!** All scenarios requiring temporary storage must be converted to persistent storage. + +**Incorrect example:** +```text +volumes: + - name: config-storage + emptyDir: {} # Error! Sealos does not support emptyDir +``` + +**Correct approach:** +- For StatefulSet: Use `volumeClaimTemplates` to create persistent storage +- For Deployment: Consider whether storage is truly needed; if so, switch to StatefulSet +- For temporary configuration: Consider using ConfigMap or Secret + +### PersistentVolumeClaim Usage Restriction + +Storage cannot create PersistentVolumeClaim independently; it must use the `volumeClaimTemplates` field within a Deployment or StatefulSet. + +### Deployment + volumeClaimTemplates — Sealos Template API Only (Important!) + +`volumeClaimTemplates` on a **Deployment** is a **Sealos-specific extension**. It works only when deployed through the Sealos Template API (`POST /api/v2alpha/templates/raw`). Standard Kubernetes `kubectl apply` will reject a Deployment with `volumeClaimTemplates`: + +``` +error: Deployment in version "v1" cannot be handled as a Deployment: + strict decoding error: unknown field "spec.volumeClaimTemplates" +``` + +**When using kubectl apply as a fallback**, you must handle this: +1. If the resource is `kind: Deployment` with `spec.volumeClaimTemplates` → remove the `volumeClaimTemplates` field and the corresponding `volumeMounts` entries before applying, OR convert to a `StatefulSet`. +2. If the resource is `kind: StatefulSet` with `spec.volumeClaimTemplates` → this is standard Kubernetes and works fine with kubectl apply. + +**Recommendation for template authors:** When persistent storage is needed, prefer `StatefulSet` over `Deployment` to ensure compatibility with both the Template API and kubectl apply. + +### volumeClaimTemplates Format + +```yaml +volumeClaimTemplates: + - metadata: + annotations: + path: /var/lib/headscale # Mount path + value: '1' # Fixed value + name: vn-varvn-libvn-headscale # Naming rules see below + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +``` + +### Naming Rules + +`metadata.name` reuses the value of `metadata.annotations.path`, with special characters replaced by "vn-": +- `/` is replaced with `vn-` +- `-` is replaced with `vn-` +- Other special characters are also replaced with `vn-` + +For example: +- `/var/lib/headscale` → `vn-varvn-libvn-headscale` +- `/usr/src/app/upload` → `vn-usrvn-srcvn-appvn-upload` +- `/cache` → `vn-cache` + +## ConfigMap Configuration Specification + +### Naming Rules + +The name of the ConfigMap must be the same as the `metadata.name` value of the application that mounts the ConfigMap. + +### File Storage Rules (Extremely Important!!!) + +**Important reminder: All key names in the ConfigMap's data field must strictly follow the vn- conversion rules!** + +All configuration files should be placed in the same ConfigMap. The key names in `data.` **must** have special characters in the mount path replaced with "vn-": + +**Conversion rules:** +- Replace `/` in the path with `vn-` +- Replace `-` in the path with `vn-` +- Replace `.` in the path with `vn-` +- Other special characters are also replaced with `vn-` + +**Incorrect example (never do this):** +```yaml +data: + inifile: | # Error! Not using vn- conversion + content here + chart.ini: | # Error! Contains a dot + content here +``` + +**Correct example:** +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +data: + # Original path: /etc/nginx/conf.d/default.conf + # After conversion: vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf + vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf: | + server { + listen 80; + ... + } + # Original path: /tmp/chart.ini + # After conversion: vn-tmpvn-chartvn-ini + vn-tmpvn-chartvn-ini: | + [cluster] + seedlist = example +``` + +### Volume Mount Specification + +#### Volumes Format + +```yaml +volumes: + - name: vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf + configMap: + name: ${{ defaults.app_name }} + items: + - key: vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf + path: ./etc/nginx/conf.d/default.conf + defaultMode: 420 +``` + +#### VolumeMount Format + +```yaml +volumeMounts: + - name: vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf + mountPath: /etc/nginx/conf.d/default.conf + subPath: ./etc/nginx/conf.d/default.conf +``` + +### Complete Example + +```yaml +# ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +data: + vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf: | + server { + listen 80; + server_name localhost; + location / { + root /usr/share/nginx/html; + index index.html; + } + } + vn-appvn-configvn-ymlvn-: | + database: + host: localhost + port: 5432 + +# Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + imagePullSecrets: + - name: ${{ defaults.app_name }} + containers: + - name: ${{ defaults.app_name }} + volumeMounts: + - name: vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf + mountPath: /etc/nginx/conf.d/default.conf + subPath: ./etc/nginx/conf.d/default.conf + - name: vn-appvn-configvn-ymlvn- + mountPath: /app/config.yml + subPath: ./app/config.yml + volumes: + - name: vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf + configMap: + name: ${{ defaults.app_name }} + items: + - key: vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf + path: ./etc/nginx/conf.d/default.conf + defaultMode: 420 + - name: vn-appvn-configvn-ymlvn- + configMap: + name: ${{ defaults.app_name }} + items: + - key: vn-appvn-configvn-ymlvn- + path: ./app/config.yml + defaultMode: 420 +``` + +## Labels and Naming Specification + +### app-deploy-manager Label Rules + +1. Application workloads (Deployment/StatefulSet/DaemonSet) must include `metadata.labels.app`, and the value must be consistent with the resource's `metadata.name` +2. The value of `cloud.sealos.io/app-deploy-manager` must be consistent with the resource's `metadata.name` value +3. The `metadata.name` of each template's main application (the frontend application providing the public-facing port) must be `${{ defaults.app_name }}` +4. Other components should be named based on `${{ defaults.app_name }}` plus a component identifier, for example: + - `${{ defaults.app_name }}-server` + - `${{ defaults.app_name }}-ml` + - `${{ defaults.app_name }}-redis` +5. Application Service must include `metadata.labels.app` and `metadata.labels.cloud.sealos.io/app-deploy-manager`, and `metadata.name`, both labels, and `spec.selector.app` must be exactly the same +6. Component-level ConfigMap must include `metadata.labels.app` and `metadata.labels.cloud.sealos.io/app-deploy-manager`, and both must be consistent with `metadata.name` +7. Application Ingress's `metadata.name` must be consistent with `metadata.labels.cloud.sealos.io/app-deploy-manager` and the backend `service.name` + +### Container Naming Rules + +The `containers.name` must be consistent with the `metadata.name` value. + +```yaml +# Correct example +metadata: + name: ${{ defaults.app_name }} +spec: + template: + spec: + containers: + - name: ${{ defaults.app_name }} # Must be consistent with metadata.name + +# Correct example for sub-components +metadata: + name: ${{ defaults.app_name }}-ml +spec: + template: + spec: + containers: + - name: ${{ defaults.app_name }}-ml # Must be consistent with metadata.name +``` + +### Example + +```yaml +# Main application (correct) +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + +# Sub-component (correct) +metadata: + name: ${{ defaults.app_name }}-ml + labels: + app: ${{ defaults.app_name }}-ml + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }}-ml + +# Incorrect example +metadata: + name: ${{ defaults.app_name }}-server + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} # Error: Label value does not match name +``` + +### Special Case: Database Resources + +Database resources (Clusters created via kubeblocks) use the special label `sealos-db-provider-cr` instead of `cloud.sealos.io/app-deploy-manager`: + +```yaml +# Correct labels for database resources +metadata: + name: ${{ defaults.app_name }}-redis + labels: + sealos-db-provider-cr: ${{ defaults.app_name }}-redis +``` + +## Object Storage Configuration + +### Environment Variable Settings + +Object storage environment variable configuration must follow this format: + +```yaml +env: + - name: S3_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: object-storage-key + key: accessKey + - name: S3_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: object-storage-key + key: secretKey + - name: S3_BUCKET + valueFrom: + secretKeyRef: + name: object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }} + key: bucket + - name: S3_ENDPOINT + value: "https://$(BACKEND_STORAGE_MINIO_EXTERNAL_ENDPOINT)" + - name: BACKEND_STORAGE_MINIO_EXTERNAL_ENDPOINT + valueFrom: + secretKeyRef: + name: object-storage-key + key: external + - name: S3_PUBLIC_DOMAIN + value: "https://$(BACKEND_STORAGE_MINIO_EXTERNAL_ENDPOINT)" + - name: S3_ENABLE_PATH_STYLE + value: "1" +``` + +### Notes + +1. `object-storage-key` is a fixed secret name (does not include the application name) +2. Only the bucket's secret name includes the application name: `object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }}`. Bucket-scoped variants may append a lowercase suffix, for example `object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }}-public`. +3. S3_ENDPOINT and S3_PUBLIC_DOMAIN use environment variable references: `$(BACKEND_STORAGE_MINIO_EXTERNAL_ENDPOINT)` +4. S3_ENABLE_PATH_STYLE must be set to "1" + +## Ingress Configuration Specification + +### Standard Format + +Ingress must use the following format: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager-domain: ${{ defaults.app_host }} + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: 32m + nginx.ingress.kubernetes.io/server-snippet: | + client_header_buffer_size 64k; + large_client_header_buffers 4 128k; + nginx.ingress.kubernetes.io/ssl-redirect: 'true' + nginx.ingress.kubernetes.io/backend-protocol: HTTP + nginx.ingress.kubernetes.io/client-body-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-send-timeout: '300' + nginx.ingress.kubernetes.io/proxy-read-timeout: '300' + nginx.ingress.kubernetes.io/configuration-snippet: | + if ($request_uri ~* \.(js|css|gif|jpe?g|png)) { + expires 30d; + add_header Cache-Control "public"; + } +spec: + rules: + - host: ${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: ${{ defaults.app_name }} + port: + number: + tls: + - hosts: + - ${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }} + secretName: ${{ SEALOS_CERT_SECRET_NAME }} +``` + +### Notes + +1. `metadata.name` must be `${{ defaults.app_name }}` +2. Must include the `cloud.sealos.io/app-deploy-manager-domain` label +3. `ssl-redirect` defaults to `'true'` +4. Includes a configuration-snippet for static resource caching +5. Backend service name must be `${{ defaults.app_name }}` + +## Database Connection Configuration + +### PostgreSQL Environment Variables + +All PostgreSQL environment variables are obtained from the secret automatically created by kubeblocks. The secret name format is: `${{ defaults.app_name }}-pg-conn-credential` + +The secret contains the following keys: +- `endpoint`: Full connection endpoint (host:port) +- `host`: Hostname +- `password`: Password +- `port`: Port number +- `username`: Username (usually postgres) + +### Usage Example + +```yaml +env: + # Configure host and port separately + - name: DB_HOSTNAME + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: host + - name: DB_PORT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: port + - name: DB_USERNAME + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + + # Or use endpoint to directly get host:port + - name: DB_ENDPOINT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: endpoint +``` + +### Other Databases + +Other databases follow the same approved secret policy, with service-FQDN exceptions where KubeBlocks only exposes credentials: +- Redis: `${{ defaults.app_name }}-redis-redis-account-default` (legacy `${{ defaults.app_name }}-redis-account-default` may be accepted); host/port may use `${{ defaults.app_name }}-redis-redis-redis.${{ SEALOS_NAMESPACE }}.svc.cluster.local` and `6379` +- MySQL: `${{ defaults.app_name }}-mysql-conn-credential` +- MongoDB: `${{ defaults.app_name }}-mongo-mongodb-account-root` (or `${{ defaults.app_name }}-mongodb-mongodb-account-root` when the Cluster name uses `${{ defaults.app_name }}-mongodb`); MongoDB URLs may use `${{ defaults.app_name }}-mongo-mongodb.${{ SEALOS_NAMESPACE }}.svc:27017` + +### PostgreSQL Database Initialization + +PostgreSQL does not create a database by default. If the application needs a custom database (rather than using the default postgres database), it must be created via a Job. + +**Important specification:** +- The database name should use the application's default value and should not be a user input parameter +- The database name should be related to the application name, typically using the application's short name or identifier +- For example: the langflow application uses the 'langflow' database, the fastgpt application uses the 'fastgpt' database + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: ${{ defaults.app_name }}-pg-init +spec: + backoffLimit: 3 + template: + spec: + containers: + - name: pgsql-init + image: postgres:16-alpine + imagePullPolicy: IfNotPresent + env: + - name: PG_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + - name: PG_ENDPOINT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: endpoint + - name: PG_DATABASE + value: langflow + command: + - /bin/sh + - -c + - | + set -eu + for i in $(seq 1 60); do + if pg_isready -h "${PG_ENDPOINT%:*}" -p "${PG_ENDPOINT##*:}" -U postgres -d postgres >/dev/null 2>&1; then + break + fi + sleep 2 + done + pg_isready -h "${PG_ENDPOINT%:*}" -p "${PG_ENDPOINT##*:}" -U postgres -d postgres >/dev/null 2>&1 + if ! psql "postgresql://postgres:$(PG_PASSWORD)@$(PG_ENDPOINT)/postgres" -tAc "SELECT 1 FROM pg_database WHERE datname='$(PG_DATABASE)'" | grep -q 1; then + psql "postgresql://postgres:$(PG_PASSWORD)@$(PG_ENDPOINT)/postgres" -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$(PG_DATABASE)\";" + fi + restartPolicy: OnFailure + ttlSecondsAfterFinished: 300 +``` + +**Notes:** +1. Job name uses the format `${{ defaults.app_name }}-pg-init` +2. Uses the `postgres:16-alpine` image to keep it lightweight +3. `ttlSecondsAfterFinished: 300` ensures the Job is automatically cleaned up 5 minutes after completion +4. The initialization script must wait for PostgreSQL to be ready first (e.g., `pg_isready`) +5. The initialization script must be idempotent (check `pg_database` first, create only if it does not exist) +6. The database name should be hardcoded in the template, using the application's default database name (e.g., 'langflow' in the example above) + +## Application Configuration Specification + +### Inter-Service Communication Rules + +**Important**: Services must reference each other using Fully Qualified Domain Names (FQDN); direct service names cannot be used. + +FQDN format: `.${{ SEALOS_NAMESPACE }}.svc.cluster.local` + +```yaml +# Correct example: Using FQDN +env: + - name: WORKER_URL + value: http://${{ defaults.app_name }}-worker.${{ SEALOS_NAMESPACE }}.svc.cluster.local:4003 + - name: COUCH_DB_URL + value: http://${{ defaults.app_name }}-svc-couchdb.${{ SEALOS_NAMESPACE }}.svc.cluster.local:5984 + - name: REDIS_URL + value: redis://:$(REDIS_PASSWORD)@${{ defaults.app_name }}-redis-redis-redis.${{ SEALOS_NAMESPACE }}.svc:6379 + +# Incorrect example: Using service name directly +# - name: WORKER_URL +# value: http://worker-service:4003 # Error: May fail to resolve +``` + +Note: Although the `.svc.cluster.local` suffix can be omitted in some cases (as in the REDIS_URL example above), it is recommended to always include the full domain name to ensure cross-namespace compatibility and clarity. + +### Environment Variable Dependency Order Rules + +**Important**: If an environment variable references another environment variable, the referenced variable must be defined before the variable that references it. + +```yaml +env: + # Correct example: REDIS_PASSWORD comes first, REDIS_URL comes after + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-redis-redis-account-default + key: password + - name: REDIS_URL + value: redis://:$(REDIS_PASSWORD)@${{ defaults.app_name }}-redis-redis-redis.${{ SEALOS_NAMESPACE }}.svc:6379 + + # Incorrect example: If REDIS_URL is defined before REDIS_PASSWORD + # - name: REDIS_URL + # value: redis://:$(REDIS_PASSWORD)@... # Error: REDIS_PASSWORD is not defined yet + # - name: REDIS_PASSWORD + # valueFrom: ... +``` + +This is because Kubernetes parses environment variables in the order they appear in the YAML. If a referenced variable has not been defined yet, the reference will fail. + +### Required Security and Resource Management Configuration + +All application Deployments or StatefulSets must include the following configurations: + +1. **automountServiceAccountToken**: Must be set to `false` to avoid unnecessary permission exposure +2. **revisionHistoryLimit**: Must be set to `1` to reduce resources consumed by historical revisions +3. **imagePullSecrets**: Must reference the app-scoped image pull Secret `${{ defaults.app_name }}` +4. **metadata.annotations**: Must include the following annotations: + - `originImageName`: Original image name + - `deploy.cloud.sealos.io/minReplicas`: Minimum replica count, typically set to `'1'` + - `deploy.cloud.sealos.io/maxReplicas`: Maximum replica count, typically set to `'1'` + +Recommended registry pull Secret model: + +- Managed workloads reference `${{ defaults.app_name }}` in `imagePullSecrets` +- `sealos-deploy` creates or refreshes that Secret automatically from local `gh` CLI credentials when the image is a private GHCR image +- If the template is deployed outside `sealos-deploy`, the operator must create the Secret manually before applying the workload + +```yaml +apiVersion: apps/v1 +kind: Deployment # or StatefulSet +metadata: + name: ${{ defaults.app_name }} + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} + annotations: + originImageName: example/app:1.0.0 # Required: Original image name + deploy.cloud.sealos.io/minReplicas: '1' # Required: Minimum replica count + deploy.cloud.sealos.io/maxReplicas: '1' # Required: Maximum replica count +spec: + revisionHistoryLimit: 1 # Must be set to 1 + template: + spec: + automountServiceAccountToken: false # Must be set to false + imagePullSecrets: + - name: ${{ defaults.app_name }} + containers: + - name: ${{ defaults.app_name }} + # Other container configuration... +``` + +### Complete Example + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${{ defaults.app_name }} + annotations: + originImageName: example/app:1.0.0 + deploy.cloud.sealos.io/minReplicas: '1' + deploy.cloud.sealos.io/maxReplicas: '1' + labels: + app: ${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }} +spec: + revisionHistoryLimit: 1 # Revision history limit set to 1 + replicas: 1 + selector: + matchLabels: + app: ${{ defaults.app_name }} + template: + metadata: + labels: + app: ${{ defaults.app_name }} + spec: + automountServiceAccountToken: false # Disable automatic service account token mounting + imagePullSecrets: + - name: ${{ defaults.app_name }} + containers: + - name: ${{ defaults.app_name }} + image: example/app:1.0.0 + imagePullPolicy: IfNotPresent +``` + +## Resource Quota Specification + +### Resource Limit Configuration + +**Important: The resources field of all containers must include both requests and limits!** + +All containers in application Deployments or StatefulSets must have resource quotas configured: + +```yaml +containers: + - name: ${{ defaults.app_name }} + image: example/app:1.0.0 + imagePullPolicy: IfNotPresent + resources: + requests: + cpu: 100m # Minimum CPU request (required) + memory: 128Mi # Minimum memory request (required) + limits: + cpu: 500m # CPU upper limit (required) + memory: 512Mi # Memory upper limit (required) +``` + +**Quota setting guidelines**: + +1. **Lightweight frontend applications** (static file serving, simple web applications): + ```yaml + resources: + requests: + cpu: 20m + memory: 25Mi + limits: + cpu: 200m + memory: 256Mi + ``` + +2. **Standard backend applications** (API services, medium-load applications): + ```yaml + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + ``` + +3. **Heavy-load applications** (AI processing, video processing, big data processing): + ```yaml + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 2000m + memory: 2Gi + ``` + +4. **AI/Machine Learning applications** (requiring GPU or large computational resources): + ```yaml + resources: + requests: + cpu: 1000m + memory: 1Gi + limits: + cpu: 4000m + memory: 4Gi + ``` + +**Quota setting explanation**: + +- **requests (request values)**: The minimum resources guaranteed for the container + - CPU uses `m` units (1000m = 1 CPU core) + - Memory uses `Mi` or `Gi` units + - Recommendation: Set requests to 70-80% of actual usage + +- **limits (limit values)**: The maximum resources the container can use + - CPU can burst up to the limit value + - Memory exceeding the limit will trigger OOM Kill + - Recommendation: Set limits to 2-4 times the requests + +**Golden rules for quota settings**: + +1. **Always set both requests and limits** + - Incorrect: Setting only requests may lead to resource starvation + - Incorrect: Setting only limits may cause scheduling failures + - Correct: Setting both guarantees performance and stability + +2. **Reasonable requests/limits ratio** + - CPU: limits can be 2-10 times the requests (CPU is compressible) + - Memory: limits should be 1.5-2 times the requests (memory is incompressible) + +3. **Adjust based on application type** + - Compute-intensive: Increase CPU quota + - Memory-intensive: Increase memory quota + - I/O-intensive: Balance CPU and memory + +4. **Monitor and adjust** + - Use conservative quotas for initial deployment + - Monitor actual resource usage + - Dynamically adjust based on monitoring data + +**Comparison examples**: + +```yaml +# Incorrect: No resource limits +containers: + - name: app + image: app:1.0.0 + imagePullPolicy: IfNotPresent + +# Incorrect: Only requests +containers: + - name: app + image: app:1.0.0 + imagePullPolicy: IfNotPresent + resources: + requests: + cpu: 100m + memory: 128Mi + +# Incorrect: Only limits +containers: + - name: app + image: app:1.0.0 + imagePullPolicy: IfNotPresent + resources: + limits: + cpu: 500m + memory: 512Mi + +# Correct: Both requests and limits are present +containers: + - name: app + image: app:1.0.0 + imagePullPolicy: IfNotPresent + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi +``` + +## Image Configuration Specification + +### Image Pull Policy + +The image pull policy for all containers must be set to `IfNotPresent`: + +```yaml +spec: + template: + spec: + containers: + - name: ${{ defaults.app_name }} + image: example/app:1.0.0 + imagePullPolicy: IfNotPresent # Must use IfNotPresent +``` + +This helps to: +- Reduce unnecessary image pulls and improve deployment speed +- Reduce pressure on the image registry +- Save network bandwidth + +## Other Notes + +(More specifications and best practices to be added) diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency.py new file mode 100644 index 00000000..e14b2bbb --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Consistency checker CLI for docker-to-sealos skill documents.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Optional, Sequence + +from check_consistency_parser import resolve_path +from check_consistency_rule_registry import REGISTERED_RULES +from check_consistency_runner import run_checks + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate docker-to-sealos doc consistency") + parser.add_argument("--skill", default="SKILL.md", help="Path to SKILL.md") + parser.add_argument("--references", default="references", help="Path to references directory") + parser.add_argument( + "--rules-file", + default="references/rules-registry.yaml", + help="Path to machine-readable rules registry", + ) + parser.add_argument("--only", default="", help="Comma-separated rule IDs to run") + parser.add_argument( + "--artifacts", + default="", + help=( + "Comma-separated additional files/directories to scan. " + "Use this to validate generated manifests such as template//index.yaml" + ), + ) + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + + skill_path = Path(args.skill).resolve() + if not skill_path.exists(): + print(f"ERROR: skill file not found: {skill_path}") + return 2 + + skill_root = skill_path.parent + references_dir = resolve_path(args.references, skill_root) + rules_file = resolve_path(args.rules_file, skill_root) + + if not references_dir.exists(): + print(f"ERROR: references directory not found: {references_dir}") + return 2 + if not rules_file.exists(): + print(f"ERROR: rules registry not found: {rules_file}") + return 2 + + only_rules = [item.strip() for item in args.only.split(",") if item.strip()] + additional_include_paths = [item.strip() for item in args.artifacts.split(",") if item.strip()] + + try: + violations = run_checks( + skill_path=skill_path, + references_dir=references_dir, + registry_path=rules_file, + only_rules=only_rules or None, + additional_include_paths=additional_include_paths or None, + ) + except ValueError as exc: + print(f"ERROR: {exc}") + return 2 + + if violations: + print("Consistency check failed with the following issues:") + for item in violations: + print(f"- [{item.rule_id}/{item.severity}] {item.path}:{item.line}: {item.message}") + return 1 + + total = len(only_rules) if only_rules else len(REGISTERED_RULES) + print(f"Consistency check passed ({total} rules).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_context.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_context.py new file mode 100644 index 00000000..be4026b4 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_context.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Context builder layer for consistency checks.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence, Tuple + +from check_consistency_models import ScanContext, Violation +from check_consistency_parser import build_context + + +@dataclass(frozen=True) +class ContextBuilder: + skill_path: Path + references_dir: Path + include_paths: Sequence[str] + + def build(self) -> Tuple[ScanContext, list[Violation]]: + return build_context( + skill_path=self.skill_path, + references_dir=self.references_dir, + include_paths=self.include_paths, + ) diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_engine.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_engine.py new file mode 100644 index 00000000..77a31df3 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_engine.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Rule-engine execution layer for consistency checks.""" + +from __future__ import annotations + +import fnmatch +from dataclasses import replace +from pathlib import Path +from typing import Dict, Mapping, Optional, Sequence + +from check_consistency_models import RegistryConfig, Rule, ScanContext, Violation + + +class RuleEngine: + def __init__( + self, + *, + config: RegistryConfig, + registered_rules: Mapping[str, Rule], + skill_root: Path, + ) -> None: + self.config = config + self.registered_rules: Dict[str, Rule] = dict(registered_rules) + self.skill_root = skill_root + + def resolve_rules(self, only_rules: Optional[Sequence[str]]) -> list[str]: + selected_rules = list(only_rules) if only_rules else list(self.config.ordered_rule_ids) + unknown = sorted(set(selected_rules) - set(self.registered_rules.keys())) + if unknown: + raise ValueError(f"unknown rule id(s): {', '.join(unknown)}") + return selected_rules + + def run( + self, + *, + context: ScanContext, + parse_violations: Sequence[Violation], + selected_rules: Sequence[str], + ) -> list[Violation]: + violations: list[Violation] = list(parse_violations) + for rule_id in selected_rules: + rule = self.registered_rules[rule_id] + default_meta = self.config.rules[rule_id] + for item in rule.check(context): + meta = self.config.rules.get(item.rule_id, default_meta) + if not self._in_rule_scope(item, meta.include_paths): + continue + violations.append(replace(item, severity=meta.severity)) + + violations.sort(key=lambda x: (str(x.path), x.line, x.rule_id, x.message)) + return violations + + def _in_rule_scope(self, violation: Violation, include_paths: Sequence[str]) -> bool: + if not include_paths: + return True + relative_path = self._as_relative_path(violation.path) + return any(fnmatch.fnmatch(relative_path, pattern) for pattern in include_paths) + + def _as_relative_path(self, path: Path) -> str: + try: + return path.resolve().relative_to(self.skill_root.resolve()).as_posix() + except ValueError: + return path.as_posix() diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_helpers_storage.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_helpers_storage.py new file mode 100644 index 00000000..994ec1c8 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_helpers_storage.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Storage-oriented helper utilities for consistency rules.""" + +from __future__ import annotations + +import re +from typing import Any, Iterator, Optional + +from check_consistency_models import STORAGE_UNIT_TO_BYTES + + +def contains_key(node: Any, key: str) -> bool: + if isinstance(node, dict): + if key in node: + return True + return any(contains_key(value, key) for value in node.values()) + if isinstance(node, list): + return any(contains_key(item, key) for item in node) + return False + + +def parse_storage_bytes(raw_value: str) -> Optional[int]: + text = raw_value.strip() + match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]*)", text) + if not match: + return None + + number = float(match.group(1)) + unit = match.group(2).lower() + factor = STORAGE_UNIT_TO_BYTES.get(unit) + if factor is None: + return None + + return int(number * factor) + + +def has_variable_expression(raw_value: str) -> bool: + text = raw_value.strip() + return "${{" in text or re.search(r"\$\([^)]+\)", text) is not None + + +def iter_pvc_storage_values(data: Any) -> Iterator[str]: + if isinstance(data, dict): + kind = data.get("kind") + if kind == "PersistentVolumeClaim": + spec = data.get("spec") + if isinstance(spec, dict): + resources = spec.get("resources") + if isinstance(resources, dict): + requests = resources.get("requests") + if isinstance(requests, dict): + storage = requests.get("storage") + if storage is not None: + yield str(storage) + + for key, value in data.items(): + if key == "volumeClaimTemplates" and isinstance(value, list): + for item in value: + if not isinstance(item, dict): + continue + spec = item.get("spec") + if not isinstance(spec, dict): + continue + resources = spec.get("resources") + if not isinstance(resources, dict): + continue + requests = resources.get("requests") + if not isinstance(requests, dict): + continue + storage = requests.get("storage") + if storage is not None: + yield str(storage) + else: + yield from iter_pvc_storage_values(value) + elif isinstance(data, list): + for item in data: + yield from iter_pvc_storage_values(item) diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_helpers_violations.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_helpers_violations.py new file mode 100644 index 00000000..2fc4d9bd --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_helpers_violations.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Violation construction helpers for consistency rules.""" + +from __future__ import annotations + +from typing import Any, Callable, List, Mapping, Optional + +from check_consistency_models import ScanContext, Violation, YamlDocument +from check_consistency_parser import find_line + +from check_consistency_helpers_workload import is_managed_app_workload_document + + +def add_doc_violation( + violations: List[Violation], + *, + rule_id: str, + doc: YamlDocument, + pattern: str, + message: str, + default_pattern: Optional[str] = None, +) -> None: + default_line = find_line(doc, default_pattern) if default_pattern else None + line = find_line(doc, pattern, default=default_line) + violations.append( + Violation( + rule_id=rule_id, + path=doc.path, + line=line, + message=message, + ) + ) + + +def check_managed_workload_setting( + context: ScanContext, + *, + rule_id: str, + value_extractor: Callable[[Mapping[str, Any]], Any], + expected: Any, + value_pattern: str, + fallback_pattern: str, + missing_message: str, + mismatch_message: str, +) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks or not is_managed_app_workload_document(doc): + continue + if not isinstance(doc.data, dict): + continue + + value = value_extractor(doc.data) + if value == expected: + continue + + add_doc_violation( + violations, + rule_id=rule_id, + doc=doc, + pattern=value_pattern, + default_pattern=fallback_pattern, + message=mismatch_message if value is not None else missing_message, + ) + + return violations diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_helpers_workload.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_helpers_workload.py new file mode 100644 index 00000000..0ccc3b6c --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_helpers_workload.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Workload-centric helper utilities for consistency rules.""" + +from __future__ import annotations + +from typing import Any, Iterator, Mapping, Optional, Tuple + +from check_consistency_models import APP_WORKLOAD_KINDS, ScanContext, YamlDocument + + +def iter_documents_by_kind(context: ScanContext, kind: str) -> Iterator[YamlDocument]: + for doc in context.yaml_documents: + if doc.skip_checks: + continue + if isinstance(doc.data, dict) and doc.data.get("kind") == kind: + yield doc + + +def iter_containers(node: Any) -> Iterator[dict]: + if isinstance(node, dict): + for child_key, child_value in node.items(): + if child_key in {"containers", "initContainers"} and isinstance(child_value, list): + for item in child_value: + if isinstance(item, dict): + yield item + yield from iter_containers(child_value) + elif isinstance(node, list): + for item in node: + yield from iter_containers(item) + + +def iter_workload_env_secret_refs(data: Mapping[str, Any]) -> Iterator[Tuple[str, str]]: + for env_name, secret_name, _ in iter_workload_env_secret_key_refs(data): + yield env_name, secret_name + + +def iter_workload_env_secret_key_refs(data: Mapping[str, Any]) -> Iterator[Tuple[str, str, Optional[str]]]: + for source, secret_name, env_name, secret_key in iter_workload_secret_refs(data): + if source == "env" and env_name is not None: + yield env_name, secret_name, secret_key + + +def iter_workload_secret_refs(data: Mapping[str, Any]) -> Iterator[Tuple[str, str, Optional[str], Optional[str]]]: + for container in iter_containers(data): + env_list = container.get("env") + if not isinstance(env_list, list): + env_list = [] + + for env_item in env_list: + if not isinstance(env_item, dict): + continue + env_name = env_item.get("name") + value_from = env_item.get("valueFrom") + if not isinstance(env_name, str) or not isinstance(value_from, dict): + continue + secret_ref = value_from.get("secretKeyRef") + if not isinstance(secret_ref, dict): + continue + secret_name = secret_ref.get("name") + secret_key = secret_ref.get("key") + if isinstance(secret_name, str): + yield "env", secret_name, env_name, secret_key if isinstance(secret_key, str) else None + + env_from_list = container.get("envFrom") + if isinstance(env_from_list, list): + for env_from_item in env_from_list: + if not isinstance(env_from_item, dict): + continue + secret_ref = env_from_item.get("secretRef") + if not isinstance(secret_ref, dict): + continue + secret_name = secret_ref.get("name") + if isinstance(secret_name, str): + yield "envFrom", secret_name, None, None + + template_spec = get_template_spec(data) + if not isinstance(template_spec, dict): + return + + volumes = template_spec.get("volumes") + if not isinstance(volumes, list): + return + + for volume in volumes: + if not isinstance(volume, dict): + continue + secret_spec = volume.get("secret") + if isinstance(secret_spec, dict): + secret_name = secret_spec.get("secretName") + if isinstance(secret_name, str): + yield "volume", secret_name, None, None + + projected = volume.get("projected") + if not isinstance(projected, dict): + continue + sources = projected.get("sources") + if not isinstance(sources, list): + continue + for source in sources: + if not isinstance(source, dict): + continue + source_secret = source.get("secret") + if not isinstance(source_secret, dict): + continue + secret_name = source_secret.get("name") + if isinstance(secret_name, str): + yield "projected", secret_name, None, None + + +def get_template_spec(data: Mapping[str, Any]) -> Optional[Mapping[str, Any]]: + spec = data.get("spec") + if not isinstance(spec, dict): + return None + template = spec.get("template") + if not isinstance(template, dict): + return None + template_spec = template.get("spec") + if not isinstance(template_spec, dict): + return None + return template_spec + + +def is_app_workload_document(doc: YamlDocument) -> bool: + if not isinstance(doc.data, dict): + return False + if doc.data.get("kind") not in APP_WORKLOAD_KINDS: + return False + template_spec = get_template_spec(doc.data) + if not isinstance(template_spec, dict): + return False + containers = template_spec.get("containers") + return isinstance(containers, list) and len(containers) > 0 + + +def has_managed_workload_marker(data: Mapping[str, Any]) -> bool: + metadata = data.get("metadata") + if not isinstance(metadata, dict): + return False + + labels = metadata.get("labels") + if isinstance(labels, dict) and "cloud.sealos.io/app-deploy-manager" in labels: + return True + + annotations = metadata.get("annotations") + if isinstance(annotations, dict) and "originImageName" in annotations: + return True + + return False + + +def is_managed_app_workload_document(doc: YamlDocument) -> bool: + if not is_app_workload_document(doc): + return False + if not has_managed_workload_marker(doc.data): + return False + return True diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_line_locator.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_line_locator.py new file mode 100644 index 00000000..fe36c904 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_line_locator.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Line-location helper with lightweight key indexing.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Dict, Optional, Sequence + + +SIMPLE_KEY_LINE_PATTERN = re.compile(r"^\s*([A-Za-z0-9_.\-/]+)\s*:") +SIMPLE_KEY_REGEX_PATTERN = re.compile(r"^\^\\s\*([A-Za-z0-9_.\\\-/]+)\\s\*:\$?$") +ESCAPED_CHAR_PATTERN = re.compile(r"\\(.)") + + +def _unescape_regex_literal(value: str) -> str: + return ESCAPED_CHAR_PATTERN.sub(r"\1", value) + + +def _extract_simple_key(pattern: str) -> Optional[str]: + match = SIMPLE_KEY_REGEX_PATTERN.fullmatch(pattern) + if match is None: + return None + return _unescape_regex_literal(match.group(1)) + + +def _build_key_index(lines: Sequence[str], start_line: int) -> Dict[str, int]: + index: Dict[str, int] = {} + for offset, line in enumerate(lines): + match = SIMPLE_KEY_LINE_PATTERN.match(line) + if match is None: + continue + key = match.group(1) + index.setdefault(key, start_line + offset) + return index + + +@dataclass +class LineLocator: + start_line: int + lines: Sequence[str] + + def __post_init__(self) -> None: + self._key_index = _build_key_index(self.lines, self.start_line) + self._pattern_cache: Dict[str, Optional[int]] = {} + + def find(self, pattern: str, default: Optional[int] = None) -> int: + if pattern in self._pattern_cache: + cached = self._pattern_cache[pattern] + return cached if cached is not None else self._default(default) + + key = _extract_simple_key(pattern) + if key is not None and key in self._key_index: + line = self._key_index[key] + self._pattern_cache[pattern] = line + return line + + regex = re.compile(pattern) + for offset, line in enumerate(self.lines): + if regex.search(line): + found = self.start_line + offset + self._pattern_cache[pattern] = found + return found + + self._pattern_cache[pattern] = None + return self._default(default) + + def _default(self, default: Optional[int]) -> int: + if default is not None: + return default + return self.start_line diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_models.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_models.py new file mode 100644 index 00000000..a8154bda --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_models.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Shared data models and constants for docker-to-sealos consistency checks.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Mapping, Sequence + +if TYPE_CHECKING: + from check_consistency_line_locator import LineLocator + + +LATEST_IMAGE_PATTERN = re.compile(r"\b(?:image|originImageName)\s*:\s*['\"]?[^#\s'\"]*:latest\b") +TEMPLATE_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$") +NEGATIVE_MARKERS = ("wrong example", "❌", "invalid example") +WORKLOAD_KINDS = {"Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob"} +APP_WORKLOAD_KINDS = {"Deployment", "StatefulSet", "DaemonSet"} +DB_SECRET_SUFFIXES = ( + "-pg-conn-credential", + "-mysql-conn-credential", + "-mongodb-account-root", + "-mongo-mongodb-account-root", + "-mongodb-mongodb-account-root", + "-redis-redis-account-default", + "-redis-account-default", + "-broker-account-admin", +) +MAX_PVC_STORAGE_BYTES = 1024 ** 3 # 1Gi +DB_COMPONENT_RESOURCE_LIMITS = {"cpu": "500m", "memory": "512Mi"} +DB_COMPONENT_RESOURCE_REQUESTS = {"cpu": "50m", "memory": "51Mi"} +STORAGE_UNIT_TO_BYTES = { + "": 1, + "k": 1000, + "m": 1000 ** 2, + "g": 1000 ** 3, + "t": 1000 ** 4, + "p": 1000 ** 5, + "e": 1000 ** 6, + "ki": 1024, + "mi": 1024 ** 2, + "gi": 1024 ** 3, + "ti": 1024 ** 4, + "pi": 1024 ** 5, + "ei": 1024 ** 6, +} +DEFAULT_SEVERITY = "error" +ALLOWED_SEVERITIES = {"error", "warning"} + + +@dataclass(frozen=True) +class YamlBlock: + path: Path + start_line: int + source: str + skip_checks: bool + + +@dataclass(frozen=True) +class YamlDocument: + path: Path + start_line: int + source: str + data: Any + skip_checks: bool + line_locator: "LineLocator" + + +@dataclass(frozen=True) +class Violation: + rule_id: str + path: Path + line: int + message: str + severity: str = DEFAULT_SEVERITY + + +CheckFunction = Callable[["ScanContext"], List[Violation]] + + +@dataclass(frozen=True) +class Rule: + rule_id: str + check: CheckFunction + + +@dataclass(frozen=True) +class RegistryRuleConfig: + rule_id: str + description: str + severity: str + include_paths: Sequence[str] + + +@dataclass(frozen=True) +class RegistryConfig: + include_paths: List[str] + rules: Dict[str, RegistryRuleConfig] + ordered_rule_ids: List[str] + + +@dataclass(frozen=True) +class ScanContext: + skill_path: Path + references_dir: Path + scanned_paths: List[Path] + file_texts: Dict[Path, str] + yaml_documents: List[YamlDocument] + + @property + def markdown_paths(self) -> List[Path]: + """Backward-compatible alias for pre-refactor callers.""" + return self.scanned_paths diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_parser.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_parser.py new file mode 100644 index 00000000..88efa286 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_parser.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Parsing and context-building utilities for consistency checks.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +import yaml + +from check_consistency_line_locator import LineLocator +from check_consistency_models import NEGATIVE_MARKERS, ScanContext, Violation, YamlBlock, YamlDocument + + +SUPPORTED_SCAN_SUFFIXES = {".md", ".yaml", ".yml"} + + +def iter_markdown_files(root: Path) -> Iterable[Path]: + for path in sorted(root.rglob("*.md")): + if path.is_file(): + yield path + + +def iter_yaml_files(root: Path) -> Iterable[Path]: + for pattern in ("*.yaml", "*.yml"): + for path in sorted(root.rglob(pattern)): + if path.is_file(): + yield path + + +def iter_supported_files(root: Path) -> Iterable[Path]: + seen: set[Path] = set() + for path in [*iter_markdown_files(root), *iter_yaml_files(root)]: + if path in seen: + continue + seen.add(path) + yield path + + +def has_negative_markers(text: str) -> bool: + lowered = text.lower() + return any(marker in lowered for marker in NEGATIVE_MARKERS) + + +def extract_yaml_blocks(path: Path, text: str) -> List[YamlBlock]: + lines = text.splitlines() + blocks: List[YamlBlock] = [] + + in_block = False + block_start = 0 + collected: List[str] = [] + block_skip_checks = False + + for index, line in enumerate(lines, start=1): + stripped = line.strip() + + if not in_block: + if stripped.startswith("```"): + lang = stripped[3:].strip().split(maxsplit=1)[0].lower() if stripped[3:].strip() else "" + if lang in {"yaml", "yml"}: + in_block = True + block_start = index + 1 + collected = [] + context_lines = lines[max(0, index - 4): index] + block_skip_checks = has_negative_markers("\n".join(context_lines)) + continue + + if stripped.startswith("```"): + source = "\n".join(collected).strip("\n") + if source: + skip_checks = block_skip_checks or has_negative_markers(source) + blocks.append( + YamlBlock(path=path, start_line=block_start, source=source, skip_checks=skip_checks) + ) + in_block = False + block_start = 0 + collected = [] + block_skip_checks = False + continue + + collected.append(line) + + return blocks + + +def split_yaml_documents(block: YamlBlock) -> List[Tuple[int, str]]: + docs: List[Tuple[int, str]] = [] + lines = block.source.splitlines() + + current: List[str] = [] + doc_start = block.start_line + + for index, line in enumerate(lines, start=block.start_line): + if re.match(r"^\s*---\s*$", line): + text = "\n".join(current).strip() + if text: + docs.append((doc_start, text)) + current = [] + doc_start = index + 1 + continue + current.append(line) + + tail = "\n".join(current).strip() + if tail: + docs.append((doc_start, tail)) + + return docs + + +def should_ignore_yaml_parse_error(doc_text: str) -> bool: + lines = [line.strip() for line in doc_text.splitlines()] + if any(line == "..." for line in lines): + return True + + template_control_prefixes = ( + "${{ if(", + "${{ elif(", + "${{ else() }}", + "${{ endif() }}", + ) + return any(line.startswith(template_prefix) for line in lines for template_prefix in template_control_prefixes) + + +def parse_yaml_documents(blocks: Sequence[YamlBlock]) -> Tuple[List[YamlDocument], List[Violation]]: + documents: List[YamlDocument] = [] + violations: List[Violation] = [] + + for block in blocks: + for start_line, doc_text in split_yaml_documents(block): + try: + parsed = yaml.safe_load(doc_text) + except yaml.YAMLError as exc: + if block.skip_checks or should_ignore_yaml_parse_error(doc_text): + continue + line = start_line + mark = getattr(exc, "problem_mark", None) + if mark is not None: + line += int(mark.line) + violations.append( + Violation( + rule_id="R000", + path=block.path, + line=line, + message=f"invalid YAML snippet: {exc.__class__.__name__}", + ) + ) + continue + + if parsed is None: + continue + + documents.append( + YamlDocument( + path=block.path, + start_line=start_line, + source=doc_text, + data=parsed, + skip_checks=block.skip_checks, + line_locator=LineLocator( + start_line=start_line, + lines=tuple(doc_text.splitlines()), + ), + ) + ) + + return documents, violations + + +def find_line(doc: YamlDocument, pattern: str, default: Optional[int] = None) -> int: + return doc.line_locator.find(pattern, default=default) + + +def resolve_path(value: str, base: Path) -> Path: + path = Path(value) + if path.is_absolute(): + return path + return (base / path).resolve() + + +def build_scan_paths(skill_path: Path, references_dir: Path, include_paths: Sequence[str]) -> List[Path]: + if not include_paths: + return [skill_path, *iter_supported_files(references_dir)] + + skill_root = skill_path.parent + resolved: List[Path] = [] + for rel in include_paths: + p = resolve_path(rel, skill_root) + if not p.exists(): + raise ValueError(f"included path does not exist: {p}") + if p.is_dir(): + resolved.extend(list(iter_supported_files(p))) + else: + if p.suffix.lower() not in SUPPORTED_SCAN_SUFFIXES: + allowed = ", ".join(sorted(SUPPORTED_SCAN_SUFFIXES)) + raise ValueError(f"unsupported included file type: {p} (allowed: {allowed})") + resolved.append(p) + + unique: List[Path] = [] + seen: set[Path] = set() + for path in resolved: + if path not in seen: + unique.append(path) + seen.add(path) + return unique + + +def build_context(skill_path: Path, references_dir: Path, include_paths: Sequence[str]) -> Tuple[ScanContext, List[Violation]]: + scan_paths = build_scan_paths(skill_path, references_dir, include_paths) + file_texts: Dict[Path, str] = {} + blocks: List[YamlBlock] = [] + + for path in scan_paths: + text = path.read_text(encoding="utf-8") + file_texts[path] = text + if path.suffix.lower() == ".md": + blocks.extend(extract_yaml_blocks(path, text)) + continue + blocks.append( + YamlBlock( + path=path, + start_line=1, + source=text, + skip_checks=False, + ) + ) + + yaml_documents, parse_violations = parse_yaml_documents(blocks) + context = ScanContext( + skill_path=skill_path, + references_dir=references_dir, + scanned_paths=scan_paths, + file_texts=file_texts, + yaml_documents=yaml_documents, + ) + return context, parse_violations diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_registry.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_registry.py new file mode 100644 index 00000000..1ba44ac2 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_registry.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Rules registry loading and validation.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Sequence + +import yaml + +from check_consistency_models import ALLOWED_SEVERITIES, DEFAULT_SEVERITY, RegistryConfig, RegistryRuleConfig + + +def _parse_global_include_paths(data: Mapping[str, Any]) -> List[str]: + include_paths: List[str] = [] + scope = data.get("scope") + if isinstance(scope, dict): + include = scope.get("include") + if include is not None: + if not isinstance(include, list) or not all(isinstance(x, str) for x in include): + raise ValueError("scope.include must be a list of strings") + include_paths = list(include) + return include_paths + + +def _parse_rule_scope(rule_entry: Mapping[str, Any]) -> List[str]: + scope = rule_entry.get("scope") + if scope is None: + return [] + if not isinstance(scope, dict): + raise ValueError(f"rule scope must be an object: {rule_entry!r}") + include_paths = scope.get("include_paths") + if include_paths is None: + return [] + if not isinstance(include_paths, list) or not all(isinstance(x, str) for x in include_paths): + raise ValueError(f"rule scope.include_paths must be a list of strings: {rule_entry!r}") + return include_paths + + +def _parse_rule_config(item: Mapping[str, Any]) -> RegistryRuleConfig: + rule_id = item.get("id") + description = item.get("description") + if not isinstance(rule_id, str) or not isinstance(description, str): + raise ValueError(f"invalid rule entry in registry: {item!r}") + + severity = item.get("severity", DEFAULT_SEVERITY) + if not isinstance(severity, str) or severity not in ALLOWED_SEVERITIES: + allowed = ", ".join(sorted(ALLOWED_SEVERITIES)) + raise ValueError(f"invalid severity for {rule_id}: {severity!r} (allowed: {allowed})") + + return RegistryRuleConfig( + rule_id=rule_id, + description=description, + severity=severity, + include_paths=_parse_rule_scope(item), + ) + + +def load_registry_config(registry_path: Path) -> RegistryConfig: + data = yaml.safe_load(registry_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"invalid rules registry format: {registry_path}") + + rules = data.get("rules") + if not isinstance(rules, list): + raise ValueError(f"invalid rules list in registry: {registry_path}") + + ordered_rule_ids: List[str] = [] + parsed_rules: Dict[str, RegistryRuleConfig] = {} + for item in rules: + if not isinstance(item, dict): + raise ValueError(f"invalid rule entry in registry: {item!r}") + parsed_rule = _parse_rule_config(item) + if parsed_rule.rule_id in parsed_rules: + raise ValueError(f"duplicate rule id in registry: {parsed_rule.rule_id}") + ordered_rule_ids.append(parsed_rule.rule_id) + parsed_rules[parsed_rule.rule_id] = parsed_rule + + return RegistryConfig( + include_paths=_parse_global_include_paths(data), + rules=parsed_rules, + ordered_rule_ids=ordered_rule_ids, + ) + + +def validate_registry(registry_path: Path, implemented_rule_ids: Iterable[str]) -> RegistryConfig: + config = load_registry_config(registry_path) + registry_ids = set(config.ordered_rule_ids) + implemented_ids = set(implemented_rule_ids) + + missing_impl = sorted(registry_ids - implemented_ids) + missing_registry = sorted(implemented_ids - registry_ids) + + if missing_impl or missing_registry: + parts: List[str] = [] + if missing_impl: + parts.append(f"rules declared but not implemented: {', '.join(missing_impl)}") + if missing_registry: + parts.append(f"rules implemented but not declared: {', '.join(missing_registry)}") + raise ValueError("; ".join(parts)) + + return config diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rule_helpers.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rule_helpers.py new file mode 100644 index 00000000..9689afd7 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rule_helpers.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Backward-compatible helper exports for consistency rules.""" + +from __future__ import annotations + +from check_consistency_helpers_storage import ( + contains_key, + has_variable_expression, + iter_pvc_storage_values, + parse_storage_bytes, +) +from check_consistency_helpers_violations import add_doc_violation, check_managed_workload_setting +from check_consistency_helpers_workload import ( + get_template_spec, + has_managed_workload_marker, + is_managed_app_workload_document, + iter_containers, + iter_documents_by_kind, + iter_workload_env_secret_refs, + iter_workload_secret_refs, +) + +__all__ = [ + "add_doc_violation", + "check_managed_workload_setting", + "contains_key", + "get_template_spec", + "has_managed_workload_marker", + "has_variable_expression", + "is_managed_app_workload_document", + "iter_containers", + "iter_documents_by_kind", + "iter_pvc_storage_values", + "iter_workload_env_secret_refs", + "iter_workload_secret_refs", + "parse_storage_bytes", +] diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rule_registry.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rule_registry.py new file mode 100644 index 00000000..47fdf105 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rule_registry.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Rule registry composition for consistency checks.""" + +from __future__ import annotations + +from typing import Dict, Mapping + +from check_consistency_models import Rule +from check_consistency_rules_app import APP_RULES +from check_consistency_rules_security import SECURITY_RULES +from check_consistency_rules_storage import STORAGE_RULES + + +def _merge_rule_sets(*rule_sets: Mapping[str, Rule]) -> Dict[str, Rule]: + merged: Dict[str, Rule] = {} + for rule_set in rule_sets: + for rule_id, rule in rule_set.items(): + if rule_id in merged: + raise ValueError(f"duplicate rule id: {rule_id}") + merged[rule_id] = rule + return merged + + +REGISTERED_RULES: Dict[str, Rule] = _merge_rule_sets( + APP_RULES, + STORAGE_RULES, + SECURITY_RULES, +) diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules.py new file mode 100644 index 00000000..69698023 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Backward-compatible exports for consistency rule registration.""" + +from __future__ import annotations + +from check_consistency_rule_registry import REGISTERED_RULES + +__all__ = ["REGISTERED_RULES"] diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules_app.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules_app.py new file mode 100644 index 00000000..8368c6c4 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules_app.py @@ -0,0 +1,1583 @@ +#!/usr/bin/env python3 +"""Application-centric consistency rules.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +from check_consistency_models import LATEST_IMAGE_PATTERN, TEMPLATE_NAME_PATTERN, Rule, ScanContext, Violation +from check_consistency_helpers_violations import ( + add_doc_violation, + check_managed_workload_setting, +) +from check_consistency_helpers_workload import ( + get_template_spec, + has_managed_workload_marker, + is_app_workload_document, + iter_containers, + iter_documents_by_kind, + iter_workload_secret_refs, +) + + +TEMPLATE_ARTIFACT_SUFFIXES = {".yaml", ".yml"} +TEMPLATE_REQUIRED_SPEC_FIELDS = { + "title": str, + "url": str, + "gitRepo": str, + "author": str, + "description": str, + "icon": str, + "templateType": str, + "locale": str, + "i18n": dict, + "categories": list, +} +FLOATING_TAG_ALIASES = {"latest", "stable", "main", "master", "edge", "nightly", "dev"} +FLOATING_NUMERIC_TAG_RE = re.compile(r"^v?\d+(?:\.\d+)?$") +COMPOSE_VAR_IN_IMAGE_RE = re.compile(r"\$(?:\{[^}]+\}|[A-Za-z_][A-Za-z0-9_]*)") +ZH_CHAR_RE = re.compile(r"[\u3400-\u4DBF\u4E00-\u9FFF]") +ALLOWED_TEMPLATE_CATEGORIES = { + "tool", + "ai", + "game", + "database", + "low-code", + "monitor", + "dev-ops", + "blog", + "storage", + "frontend", + "backend", +} +TEMPLATE_README_BASE = "https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template" +HTTP_INGRESS_REQUIRED_ANNOTATIONS: Dict[str, str] = { + "kubernetes.io/ingress.class": "nginx", + "nginx.ingress.kubernetes.io/proxy-body-size": "32m", + "nginx.ingress.kubernetes.io/server-snippet": ( + "client_header_buffer_size 64k;\n" + "large_client_header_buffers 4 128k;" + ), + "nginx.ingress.kubernetes.io/ssl-redirect": "true", + "nginx.ingress.kubernetes.io/backend-protocol": "HTTP", + "nginx.ingress.kubernetes.io/client-body-buffer-size": "64k", + "nginx.ingress.kubernetes.io/proxy-buffer-size": "64k", + "nginx.ingress.kubernetes.io/proxy-send-timeout": "300", + "nginx.ingress.kubernetes.io/proxy-read-timeout": "300", + "nginx.ingress.kubernetes.io/configuration-snippet": ( + "if ($request_uri ~* \\.(js|css|gif|jpe?g|png)) {\n" + " expires 30d;\n" + " add_header Cache-Control \"public\";\n" + "}" + ), +} +CRONJOB_LABEL_KEY = "cloud.sealos.io/cronjob" +CRONJOB_REQUIRED_LABELS: Dict[str, str] = { + "cronjob-launchpad-name": "", + "cronjob-type": "image", +} +POSTGRES_URL_DATABASE_RE = re.compile(r"postgres(?:ql)?://[^/\s]+/([^?\s'\";]+)", re.IGNORECASE) +DEFAULT_POSTGRES_DATABASE_NAMES = {"postgres", "template0", "template1"} +OFFICIAL_HEALTH_HTTP_EXPECTATIONS: Dict[str, Dict[str, str]] = { + "goauthentik/server": { + "liveness_path": "/-/health/live/", + "readiness_path": "/-/health/ready/", + "startup_path": "/-/health/ready/", + } +} +OFFICIAL_HEALTH_WORKER_EXEC_EXPECTATIONS: Dict[str, Dict[str, str]] = { + "goauthentik/server": { + "liveness_command": "ak healthcheck", + "readiness_command": "ak healthcheck", + "startup_command": "ak healthcheck", + }, +} + + +def _iter_template_artifact_documents(context: ScanContext) -> Iterable: + for doc in iter_documents_by_kind(context, "Template"): + if doc.path.suffix.lower() in TEMPLATE_ARTIFACT_SUFFIXES: + yield doc + + +def _is_non_empty_value(value: Any, expected_type: type) -> bool: + if expected_type is str: + return isinstance(value, str) and bool(value.strip()) + if expected_type is dict: + return isinstance(value, dict) and len(value) > 0 + if expected_type is list: + return isinstance(value, list) and len(value) > 0 + return isinstance(value, expected_type) + + +def _extract_template_directory_name(path: Path) -> str: + parts = path.parts + if "template" not in parts: + return "" + index = parts.index("template") + if index + 1 >= len(parts): + return "" + return parts[index + 1] + + +def check_no_latest_tags(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks: + continue + for line_no, line in enumerate(doc.source.splitlines(), start=doc.start_line): + if LATEST_IMAGE_PATTERN.search(line): + violations.append( + Violation( + rule_id="R001", + path=doc.path, + line=line_no, + message="forbidden ':latest' image tag", + ) + ) + return violations + + +def _extract_image_tag(image: str) -> Optional[str]: + text = image.strip() + if not text or "@sha256:" in text: + return None + without_digest = text.split("@", 1)[0] + last_segment = without_digest.rsplit("/", 1)[-1] + if ":" not in last_segment: + return None + return last_segment.rsplit(":", 1)[-1].strip() + + +def _is_floating_tag(tag: str) -> bool: + normalized = tag.strip().lower() + if normalized in FLOATING_TAG_ALIASES: + return True + return FLOATING_NUMERIC_TAG_RE.fullmatch(normalized) is not None + + +def check_no_floating_image_tags(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + if not is_app_workload_document(doc): + continue + if not has_managed_workload_marker(doc.data): + continue + + metadata = doc.data.get("metadata") + annotations = metadata.get("annotations") if isinstance(metadata, dict) else None + origin_image = annotations.get("originImageName") if isinstance(annotations, dict) else None + values: List[tuple[str, str]] = [] + if isinstance(origin_image, str) and origin_image.strip(): + values.append(("originImageName", origin_image.strip())) + + template_spec = get_template_spec(doc.data) + containers = template_spec.get("containers") if isinstance(template_spec, dict) else None + if isinstance(containers, list): + for container in containers: + if not isinstance(container, dict): + continue + image = container.get("image") + if isinstance(image, str) and image.strip(): + values.append(("image", image.strip())) + + for field_name, image_value in values: + tag = _extract_image_tag(image_value) + if tag is None or not _is_floating_tag(tag): + continue + pattern = r"originImageName" if field_name == "originImageName" else r"^\s*image\s*:" + add_doc_violation( + violations, + rule_id="R016", + doc=doc, + pattern=pattern, + default_pattern=r"^\s*metadata\s*:" if field_name == "originImageName" else r"^\s*containers\s*:", + message=( + f"floating image tag '{tag}' is not allowed; " + "use an explicit version tag (e.g. v2.2.0) or digest" + ), + ) + + return violations + + +def check_no_compose_image_variables(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + if not is_app_workload_document(doc): + continue + if not has_managed_workload_marker(doc.data): + continue + + metadata = doc.data.get("metadata") + annotations = metadata.get("annotations") if isinstance(metadata, dict) else None + origin_image = annotations.get("originImageName") if isinstance(annotations, dict) else None + values: List[tuple[str, str]] = [] + if isinstance(origin_image, str) and origin_image.strip(): + values.append(("originImageName", origin_image.strip())) + + template_spec = get_template_spec(doc.data) + containers = template_spec.get("containers") if isinstance(template_spec, dict) else None + if isinstance(containers, list): + for container in containers: + if not isinstance(container, dict): + continue + image = container.get("image") + if isinstance(image, str) and image.strip(): + values.append(("image", image.strip())) + + for field_name, image_value in values: + if COMPOSE_VAR_IN_IMAGE_RE.search(image_value) is None: + continue + pattern = r"originImageName" if field_name == "originImageName" else r"^\s*image\s*:" + add_doc_violation( + violations, + rule_id="R018", + doc=doc, + pattern=pattern, + default_pattern=r"^\s*metadata\s*:" if field_name == "originImageName" else r"^\s*containers\s*:", + message=( + "image references must be concrete and must not contain Compose-style variables; " + "resolve to explicit tag or digest before emitting template artifacts" + ), + ) + return violations + + +def check_app_no_spec_template(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in iter_documents_by_kind(context, "App"): + spec = doc.data.get("spec") if isinstance(doc.data, dict) else None + if isinstance(spec, dict) and "template" in spec: + add_doc_violation( + violations, + rule_id="R002", + doc=doc, + pattern=r"^\s*template\s*:", + default_pattern=r"^\s*spec\s*:", + message="App resource must not use spec.template", + ) + return violations + + +def check_app_has_spec_data_url(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in iter_documents_by_kind(context, "App"): + spec = doc.data.get("spec") if isinstance(doc.data, dict) else None + data = spec.get("data") if isinstance(spec, dict) else None + url = data.get("url") if isinstance(data, dict) else None + if not isinstance(url, str) or not url.strip(): + add_doc_violation( + violations, + rule_id="R003", + doc=doc, + pattern=r"^\s*data\s*:", + default_pattern=r"^\s*spec\s*:", + message="App resource must define spec.data.url", + ) + return violations + + +def _check_app_spec_exact_string( + context: ScanContext, + *, + rule_id: str, + field_name: str, + expected: str, +) -> List[Violation]: + violations: List[Violation] = [] + for doc in iter_documents_by_kind(context, "App"): + spec = doc.data.get("spec") if isinstance(doc.data, dict) else None + value = spec.get(field_name) if isinstance(spec, dict) else None + if not isinstance(value, str) or not value.strip(): + add_doc_violation( + violations, + rule_id=rule_id, + doc=doc, + pattern=rf"^\s*{re.escape(field_name)}\s*:", + default_pattern=r"^\s*spec\s*:", + message=f"App resource must define spec.{field_name}: {expected}", + ) + continue + + if value.strip() != expected: + add_doc_violation( + violations, + rule_id=rule_id, + doc=doc, + pattern=rf"^\s*{re.escape(field_name)}\s*:", + default_pattern=r"^\s*spec\s*:", + message=f"App resource spec.{field_name} must be {expected!r}", + ) + return violations + + +def check_app_display_type_normal(context: ScanContext) -> List[Violation]: + return _check_app_spec_exact_string( + context, + rule_id="R032", + field_name="displayType", + expected="normal", + ) + + +def check_app_type_link(context: ScanContext) -> List[Violation]: + return _check_app_spec_exact_string( + context, + rule_id="R033", + field_name="type", + expected="link", + ) + + +def check_template_name_is_hardcoded_lowercase(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in iter_documents_by_kind(context, "Template"): + metadata = doc.data.get("metadata") if isinstance(doc.data, dict) else None + name = metadata.get("name") if isinstance(metadata, dict) else None + + if not isinstance(name, str): + add_doc_violation( + violations, + rule_id="R004", + doc=doc, + pattern=r"^\s*metadata\s*:", + message="Template metadata.name must be a hardcoded lowercase string", + ) + continue + + if "${{" in name or not TEMPLATE_NAME_PATTERN.fullmatch(name): + add_doc_violation( + violations, + rule_id="R004", + doc=doc, + pattern=r"^\s*name\s*:", + message="Template metadata.name must be hardcoded lowercase and must not use variables", + ) + + return violations + + +def check_template_required_metadata_fields(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in _iter_template_artifact_documents(context): + spec = doc.data.get("spec") if isinstance(doc.data, dict) else None + if not isinstance(spec, dict): + add_doc_violation( + violations, + rule_id="R012", + doc=doc, + pattern=r"^\s*spec\s*:", + message="Template must define spec with required metadata fields", + ) + continue + + for field, expected_type in TEMPLATE_REQUIRED_SPEC_FIELDS.items(): + if _is_non_empty_value(spec.get(field), expected_type): + continue + add_doc_violation( + violations, + rule_id="R012", + doc=doc, + pattern=rf"^\s*{re.escape(field)}\s*:", + default_pattern=r"^\s*spec\s*:", + message=f"Template spec.{field} must be defined and non-empty", + ) + return violations + + +def check_template_folder_matches_name(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in _iter_template_artifact_documents(context): + if doc.path.name != "index.yaml": + continue + expected_name = _extract_template_directory_name(doc.path.resolve()) + if not expected_name: + continue + + metadata = doc.data.get("metadata") if isinstance(doc.data, dict) else None + actual_name = metadata.get("name") if isinstance(metadata, dict) else None + if not isinstance(actual_name, str): + continue + if expected_name == actual_name: + continue + add_doc_violation( + violations, + rule_id="R013", + doc=doc, + pattern=r"^\s*name\s*:", + default_pattern=r"^\s*metadata\s*:", + message=f"Template folder name '{expected_name}' must match metadata.name '{actual_name}'", + ) + return violations + + +def check_template_icon_paths(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in _iter_template_artifact_documents(context): + metadata = doc.data.get("metadata") if isinstance(doc.data, dict) else None + spec = doc.data.get("spec") if isinstance(doc.data, dict) else None + app_name = metadata.get("name") if isinstance(metadata, dict) else None + if not isinstance(app_name, str) or not isinstance(spec, dict): + continue + + icon = spec.get("icon") + if isinstance(icon, str): + icon_pattern = re.compile( + rf"^https://raw\.githubusercontent\.com/.+/kb-0\.9/template/{re.escape(app_name)}/logo\.[A-Za-z0-9]+$" + ) + if icon_pattern.fullmatch(icon.strip()) is None: + add_doc_violation( + violations, + rule_id="R014", + doc=doc, + pattern=r"^\s*icon\s*:", + default_pattern=r"^\s*spec\s*:", + message="Template spec.icon must point to raw.githubusercontent.com/.../kb-0.9/template//logo.", + ) + return violations + + +def check_template_readme_paths(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in _iter_template_artifact_documents(context): + metadata = doc.data.get("metadata") if isinstance(doc.data, dict) else None + spec = doc.data.get("spec") if isinstance(doc.data, dict) else None + app_name = metadata.get("name") if isinstance(metadata, dict) else None + if not isinstance(app_name, str) or not isinstance(spec, dict): + continue + + expected_readme = f"{TEMPLATE_README_BASE}/{app_name}/README.md" + expected_zh_readme = f"{TEMPLATE_README_BASE}/{app_name}/README_zh.md" + + readme = spec.get("readme") + if not (isinstance(readme, str) and readme.strip() == expected_readme): + add_doc_violation( + violations, + rule_id="R025", + doc=doc, + pattern=r"^\s*readme\s*:", + default_pattern=r"^\s*spec\s*:", + message=f"Template spec.readme must be '{expected_readme}'", + ) + + i18n = spec.get("i18n") if isinstance(spec, dict) else None + zh = i18n.get("zh") if isinstance(i18n, dict) else None + zh_readme = zh.get("readme") if isinstance(zh, dict) else None + if not (isinstance(zh_readme, str) and zh_readme.strip() == expected_zh_readme): + add_doc_violation( + violations, + rule_id="R025", + doc=doc, + pattern=r"^\s*i18n\s*:", + default_pattern=r"^\s*spec\s*:", + message=f"Template spec.i18n.zh.readme must be '{expected_zh_readme}'", + ) + + return violations + + +def check_template_i18n_zh_description_chinese(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in _iter_template_artifact_documents(context): + spec = doc.data.get("spec") if isinstance(doc.data, dict) else None + i18n = spec.get("i18n") if isinstance(spec, dict) else None + zh = i18n.get("zh") if isinstance(i18n, dict) else None + description = zh.get("description") if isinstance(zh, dict) else None + + if isinstance(description, str) and description.strip() and ZH_CHAR_RE.search(description): + continue + + add_doc_violation( + violations, + rule_id="R021", + doc=doc, + pattern=r"^\s*i18n\s*:", + default_pattern=r"^\s*spec\s*:", + message="Template spec.i18n.zh.description must be provided in Simplified Chinese", + ) + return violations + + +def check_template_i18n_zh_title_absent(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in _iter_template_artifact_documents(context): + spec = doc.data.get("spec") if isinstance(doc.data, dict) else None + i18n = spec.get("i18n") if isinstance(spec, dict) else None + zh = i18n.get("zh") if isinstance(i18n, dict) else None + if not isinstance(zh, dict): + continue + if "title" not in zh: + continue + + add_doc_violation( + violations, + rule_id="R022", + doc=doc, + pattern=r"^\s*i18n\s*:", + default_pattern=r"^\s*spec\s*:", + message="Template spec.i18n.zh.title should be omitted when it is identical to spec.title", + ) + return violations + + +def check_template_categories_allowed(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + allowed = ", ".join(sorted(ALLOWED_TEMPLATE_CATEGORIES)) + for doc in _iter_template_artifact_documents(context): + spec = doc.data.get("spec") if isinstance(doc.data, dict) else None + categories = spec.get("categories") if isinstance(spec, dict) else None + if not isinstance(categories, list): + continue + for item in categories: + if isinstance(item, str) and item in ALLOWED_TEMPLATE_CATEGORIES: + continue + add_doc_violation( + violations, + rule_id="R023", + doc=doc, + pattern=r"^\s*categories\s*:", + default_pattern=r"^\s*spec\s*:", + message=f"Template spec.categories entries must be from allowlist: {allowed}", + ) + break + return violations + + +def check_deploy_manager_label_match_name(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + label_key = "cloud.sealos.io/app-deploy-manager" + + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + if not is_app_workload_document(doc): + continue + metadata = doc.data.get("metadata") + if not isinstance(metadata, dict): + continue + name = metadata.get("name") + labels = metadata.get("labels") + if not isinstance(name, str): + continue + + label_value = labels.get(label_key) if isinstance(labels, dict) else None + if label_value is None: + add_doc_violation( + violations, + rule_id="R008", + doc=doc, + pattern=r"^\s*labels\s*:", + default_pattern=r"^\s*metadata\s*:", + message=f"{label_key} label is required and must exactly match metadata.name", + ) + continue + if label_value != name: + add_doc_violation( + violations, + rule_id="R008", + doc=doc, + pattern=re.escape(label_key), + message=f"{label_key} must exactly match metadata.name", + ) + + return violations + + +def check_app_label_match_name(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + label_key = "app" + + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + if not is_app_workload_document(doc): + continue + if not has_managed_workload_marker(doc.data): + continue + + metadata = doc.data.get("metadata") + if not isinstance(metadata, dict): + continue + name = metadata.get("name") + if not isinstance(name, str) or not name.strip(): + continue + + labels = metadata.get("labels") + label_value = labels.get(label_key) if isinstance(labels, dict) else None + if not isinstance(label_value, str) or not label_value.strip(): + add_doc_violation( + violations, + rule_id="R034", + doc=doc, + pattern=r"^\s*app\s*:", + default_pattern=r"^\s*metadata\s*:", + message="metadata.labels.app is required and must exactly match metadata.name for managed app workloads", + ) + continue + if label_value != name: + add_doc_violation( + violations, + rule_id="R034", + doc=doc, + pattern=r"^\s*app\s*:", + default_pattern=r"^\s*metadata\s*:", + message="metadata.labels.app must exactly match metadata.name for managed app workloads", + ) + + return violations + + +def check_container_names_match_workload_name(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + if not is_app_workload_document(doc): + continue + if not has_managed_workload_marker(doc.data): + continue + + metadata = doc.data.get("metadata") + if not isinstance(metadata, dict): + continue + workload_name = metadata.get("name") + if not isinstance(workload_name, str) or not workload_name.strip(): + continue + + template_spec = get_template_spec(doc.data) + containers = template_spec.get("containers") if isinstance(template_spec, dict) else None + if not isinstance(containers, list): + continue + + for container in containers: + if not isinstance(container, dict): + continue + container_name = container.get("name") + if isinstance(container_name, str) and container_name.strip() == workload_name: + continue + + if isinstance(container_name, str) and container_name.strip(): + pattern = rf"^\s*-\s*name\s*:\s*{re.escape(container_name.strip())}\s*$" + message = ( + f"container name '{container_name.strip()}' must exactly match metadata.name " + f"'{workload_name}' for managed app workloads" + ) + else: + pattern = r"^\s*-\s*name\s*:" + message = ( + "container name is required and must exactly match metadata.name " + "for managed app workloads" + ) + + add_doc_violation( + violations, + rule_id="R028", + doc=doc, + pattern=pattern, + default_pattern=r"^\s*containers\s*:", + message=message, + ) + + return violations + + +def check_origin_image_name_matches_container(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + if doc.path.suffix.lower() not in TEMPLATE_ARTIFACT_SUFFIXES: + continue + if not is_app_workload_document(doc): + continue + if not has_managed_workload_marker(doc.data): + continue + + metadata = doc.data.get("metadata") + annotations = metadata.get("annotations") if isinstance(metadata, dict) else None + origin_image = annotations.get("originImageName") if isinstance(annotations, dict) else None + template_spec = get_template_spec(doc.data) + containers = template_spec.get("containers") if isinstance(template_spec, dict) else None + images = [item.get("image") for item in containers or [] if isinstance(item, dict)] + image_values = [image.strip() for image in images if isinstance(image, str) and image.strip()] + if not image_values: + continue + + if not isinstance(origin_image, str) or not origin_image.strip(): + add_doc_violation( + violations, + rule_id="R015", + doc=doc, + pattern=r"originImageName", + default_pattern=r"^\s*metadata\s*:", + message="managed app workloads must define metadata.annotations.originImageName", + ) + continue + if origin_image.strip() not in image_values: + add_doc_violation( + violations, + rule_id="R015", + doc=doc, + pattern=r"originImageName", + default_pattern=r"^\s*metadata\s*:", + message="metadata.annotations.originImageName must match a container image in the workload", + ) + return violations + + +def check_service_ports_have_names(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in iter_documents_by_kind(context, "Service"): + if doc.path.suffix.lower() not in TEMPLATE_ARTIFACT_SUFFIXES: + continue + if doc.path.name != "index.yaml": + continue + spec = doc.data.get("spec") if isinstance(doc.data, dict) else None + ports = spec.get("ports") if isinstance(spec, dict) else None + if not isinstance(ports, list): + continue + for entry in ports: + if not isinstance(entry, dict): + continue + port_value = entry.get("port") + name = entry.get("name") + if isinstance(name, str) and name.strip(): + continue + pattern = ( + rf"^\s*port\s*:\s*{re.escape(str(port_value))}\s*$" + if port_value is not None + else r"^\s*ports\s*:" + ) + add_doc_violation( + violations, + rule_id="R020", + doc=doc, + pattern=pattern, + default_pattern=r"^\s*ports\s*:", + message="Service spec.ports entries must define a non-empty name", + ) + return violations + + +def check_service_labels_match_selector_app(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + cloud_label_key = "cloud.sealos.io/app-deploy-manager" + for doc in iter_documents_by_kind(context, "Service"): + if doc.path.suffix.lower() not in TEMPLATE_ARTIFACT_SUFFIXES: + continue + if doc.path.name != "index.yaml": + continue + if not isinstance(doc.data, dict): + continue + + spec = doc.data.get("spec") + selector = spec.get("selector") if isinstance(spec, dict) else None + selector_app = selector.get("app") if isinstance(selector, dict) else None + if not isinstance(selector_app, str) or not selector_app.strip(): + continue + selector_app = selector_app.strip() + + metadata = doc.data.get("metadata") + metadata_name = metadata.get("name") if isinstance(metadata, dict) else None + labels = metadata.get("labels") if isinstance(metadata, dict) else None + app_label = labels.get("app") if isinstance(labels, dict) else None + cloud_label = labels.get(cloud_label_key) if isinstance(labels, dict) else None + + if not isinstance(metadata_name, str) or not metadata_name.strip(): + add_doc_violation( + violations, + rule_id="R029", + doc=doc, + pattern=r"^\s*name\s*:", + default_pattern=r"^\s*metadata\s*:", + message="Service metadata.name is required and must match spec.selector.app", + ) + continue + metadata_name = metadata_name.strip() + + if metadata_name != selector_app: + add_doc_violation( + violations, + rule_id="R029", + doc=doc, + pattern=r"^\s*name\s*:", + default_pattern=r"^\s*metadata\s*:", + message="Service metadata.name must match spec.selector.app", + ) + + if not isinstance(app_label, str) or not app_label.strip(): + add_doc_violation( + violations, + rule_id="R029", + doc=doc, + pattern=r"^\s*labels\s*:", + default_pattern=r"^\s*metadata\s*:", + message="Service metadata.labels.app is required and must match metadata.name/spec.selector.app", + ) + elif app_label.strip() != metadata_name: + add_doc_violation( + violations, + rule_id="R029", + doc=doc, + pattern=r"^\s*app\s*:", + default_pattern=r"^\s*labels\s*:", + message="Service metadata.labels.app must match metadata.name/spec.selector.app", + ) + + if not isinstance(cloud_label, str) or not cloud_label.strip(): + add_doc_violation( + violations, + rule_id="R029", + doc=doc, + pattern=re.escape(cloud_label_key), + default_pattern=r"^\s*labels\s*:", + message=( + "Service metadata.labels.cloud.sealos.io/app-deploy-manager is required " + "and must match metadata.name/spec.selector.app" + ), + ) + elif cloud_label.strip() != metadata_name: + add_doc_violation( + violations, + rule_id="R029", + doc=doc, + pattern=re.escape(cloud_label_key), + default_pattern=r"^\s*labels\s*:", + message="Service metadata.labels.cloud.sealos.io/app-deploy-manager must match metadata.name/spec.selector.app", + ) + + return violations + + +def check_configmap_labels_match_name(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + cloud_label_key = "cloud.sealos.io/app-deploy-manager" + for doc in iter_documents_by_kind(context, "ConfigMap"): + if doc.path.suffix.lower() not in TEMPLATE_ARTIFACT_SUFFIXES: + continue + if doc.path.name != "index.yaml": + continue + if not isinstance(doc.data, dict): + continue + + metadata = doc.data.get("metadata") + metadata_name = metadata.get("name") if isinstance(metadata, dict) else None + labels = metadata.get("labels") if isinstance(metadata, dict) else None + app_label = labels.get("app") if isinstance(labels, dict) else None + cloud_label = labels.get(cloud_label_key) if isinstance(labels, dict) else None + + if not isinstance(metadata_name, str) or not metadata_name.strip(): + continue + metadata_name = metadata_name.strip() + + if not isinstance(app_label, str) or not app_label.strip(): + add_doc_violation( + violations, + rule_id="R030", + doc=doc, + pattern=r"^\s*labels\s*:", + default_pattern=r"^\s*metadata\s*:", + message="ConfigMap metadata.labels.app is required and must match metadata.name", + ) + elif app_label.strip() != metadata_name: + add_doc_violation( + violations, + rule_id="R030", + doc=doc, + pattern=r"^\s*app\s*:", + default_pattern=r"^\s*labels\s*:", + message="ConfigMap metadata.labels.app must match metadata.name", + ) + + if not isinstance(cloud_label, str) or not cloud_label.strip(): + add_doc_violation( + violations, + rule_id="R030", + doc=doc, + pattern=re.escape(cloud_label_key), + default_pattern=r"^\s*labels\s*:", + message="ConfigMap metadata.labels.cloud.sealos.io/app-deploy-manager is required and must match metadata.name", + ) + elif cloud_label.strip() != metadata_name: + add_doc_violation( + violations, + rule_id="R030", + doc=doc, + pattern=re.escape(cloud_label_key), + default_pattern=r"^\s*labels\s*:", + message="ConfigMap metadata.labels.cloud.sealos.io/app-deploy-manager must match metadata.name", + ) + + return violations + + +def _iter_ingress_backend_service_names(data: Dict[str, Any]) -> Iterable[str]: + spec = data.get("spec") + rules = spec.get("rules") if isinstance(spec, dict) else None + if not isinstance(rules, list): + return + for rule in rules: + http = rule.get("http") if isinstance(rule, dict) else None + paths = http.get("paths") if isinstance(http, dict) else None + if not isinstance(paths, list): + continue + for path in paths: + backend = path.get("backend") if isinstance(path, dict) else None + service = backend.get("service") if isinstance(backend, dict) else None + service_name = service.get("name") if isinstance(service, dict) else None + if isinstance(service_name, str) and service_name.strip(): + yield service_name.strip() + + +def check_ingress_name_matches_backends(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + cloud_label_key = "cloud.sealos.io/app-deploy-manager" + for doc in iter_documents_by_kind(context, "Ingress"): + if doc.path.suffix.lower() not in TEMPLATE_ARTIFACT_SUFFIXES: + continue + if doc.path.name != "index.yaml": + continue + if not isinstance(doc.data, dict): + continue + + metadata = doc.data.get("metadata") + metadata_name = metadata.get("name") if isinstance(metadata, dict) else None + labels = metadata.get("labels") if isinstance(metadata, dict) else None + cloud_label = labels.get(cloud_label_key) if isinstance(labels, dict) else None + if not isinstance(metadata_name, str) or not metadata_name.strip(): + continue + metadata_name = metadata_name.strip() + + if not isinstance(cloud_label, str) or not cloud_label.strip(): + add_doc_violation( + violations, + rule_id="R031", + doc=doc, + pattern=re.escape(cloud_label_key), + default_pattern=r"^\s*labels\s*:", + message="Ingress metadata.labels.cloud.sealos.io/app-deploy-manager is required and must match metadata.name", + ) + elif cloud_label.strip() != metadata_name: + add_doc_violation( + violations, + rule_id="R031", + doc=doc, + pattern=re.escape(cloud_label_key), + default_pattern=r"^\s*labels\s*:", + message="Ingress metadata.labels.cloud.sealos.io/app-deploy-manager must match metadata.name", + ) + + for backend_name in _iter_ingress_backend_service_names(doc.data): + if backend_name == metadata_name: + continue + add_doc_violation( + violations, + rule_id="R031", + doc=doc, + pattern=r"^\s*name\s*:", + default_pattern=r"^\s*service\s*:", + message="Ingress backend service.name must match Ingress metadata.name", + ) + break + + return violations + + +def _normalize_annotation_value(value: Any) -> Optional[str]: + if isinstance(value, str): + return "\n".join(line.rstrip() for line in value.strip().splitlines()) + if value is None: + return None + return str(value).strip() + + +def check_http_ingress_annotations(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in iter_documents_by_kind(context, "Ingress"): + if doc.path.suffix.lower() not in TEMPLATE_ARTIFACT_SUFFIXES: + continue + if doc.path.name != "index.yaml": + continue + if not isinstance(doc.data, dict): + continue + + metadata = doc.data.get("metadata") + annotations = metadata.get("annotations") if isinstance(metadata, dict) else None + if not isinstance(annotations, dict): + add_doc_violation( + violations, + rule_id="R026", + doc=doc, + pattern=r"^\s*annotations\s*:", + default_pattern=r"^\s*metadata\s*:", + message="Ingress metadata.annotations must define the required HTTP annotation set", + ) + continue + + backend_protocol = _normalize_annotation_value( + annotations.get("nginx.ingress.kubernetes.io/backend-protocol") + ) + if backend_protocol is not None and backend_protocol.upper() != "HTTP": + continue + + for key, expected in HTTP_INGRESS_REQUIRED_ANNOTATIONS.items(): + actual_normalized = _normalize_annotation_value(annotations.get(key)) + expected_normalized = _normalize_annotation_value(expected) + if actual_normalized == expected_normalized: + continue + add_doc_violation( + violations, + rule_id="R026", + doc=doc, + pattern=re.escape(key), + default_pattern=r"^\s*annotations\s*:", + message=f"Ingress annotation '{key}' must match the required HTTP default", + ) + return violations + + +def _is_template_artifact_document(doc) -> bool: + return doc.path.suffix.lower() in TEMPLATE_ARTIFACT_SUFFIXES and doc.path.name == "index.yaml" + + +def _extract_postgres_database_names_from_value(raw_value: str) -> List[str]: + names: List[str] = [] + for match in POSTGRES_URL_DATABASE_RE.finditer(raw_value): + db_name = match.group(1).strip() + if not db_name: + continue + normalized = db_name.lower() + if normalized in DEFAULT_POSTGRES_DATABASE_NAMES: + continue + names.append(db_name) + return names + + +def _extract_required_postgres_databases(doc) -> set[str]: + names: set[str] = set() + template_spec = get_template_spec(doc.data) + if not isinstance(template_spec, dict): + return names + for container in iter_containers(template_spec): + env_list = container.get("env") + if not isinstance(env_list, list): + continue + for env_item in env_list: + if not isinstance(env_item, dict): + continue + value = env_item.get("value") + if not isinstance(value, str): + continue + names.update(_extract_postgres_database_names_from_value(value)) + return names + + +def _is_postgres_cluster_document(doc) -> bool: + if not isinstance(doc.data, dict) or doc.data.get("kind") != "Cluster": + return False + spec = doc.data.get("spec") if isinstance(doc.data.get("spec"), dict) else {} + metadata = doc.data.get("metadata") if isinstance(doc.data.get("metadata"), dict) else {} + labels = metadata.get("labels") if isinstance(metadata.get("labels"), dict) else {} + + cluster_definition = spec.get("clusterDefinitionRef") + if isinstance(cluster_definition, str) and cluster_definition.strip().lower() == "postgresql": + return True + + label_definition = labels.get("clusterdefinition.kubeblocks.io/name") + if isinstance(label_definition, str) and label_definition.strip().lower() == "postgresql": + return True + + db_label = labels.get("kb.io/database") + if isinstance(db_label, str) and db_label.strip().lower().startswith("postgresql"): + return True + + return False + + +def _collect_postgres_expected_conn_secrets(artifact_docs) -> Dict[Path, set[str]]: + expected_by_path: Dict[Path, set[str]] = {} + for doc in artifact_docs: + if not _is_postgres_cluster_document(doc): + continue + metadata = doc.data.get("metadata") if isinstance(doc.data, dict) else None + cluster_name = metadata.get("name") if isinstance(metadata, dict) else None + if not isinstance(cluster_name, str) or not cluster_name.strip(): + continue + expected_by_path.setdefault(doc.path, set()).add(f"{cluster_name.strip()}-conn-credential") + return expected_by_path + + +def check_postgres_secret_refs_match_cluster_name(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + + artifact_docs = [doc for doc in context.yaml_documents if _is_template_artifact_document(doc)] + if not artifact_docs: + return violations + + expected_by_path = _collect_postgres_expected_conn_secrets(artifact_docs) + if not expected_by_path: + return violations + + seen: set[tuple[Path, str]] = set() + for doc in artifact_docs: + if not isinstance(doc.data, dict): + continue + expected = expected_by_path.get(doc.path) + if not expected: + continue + + for _, secret_name, _, secret_key in iter_workload_secret_refs(doc.data): + if not isinstance(secret_name, str) or not secret_name.endswith("-pg-conn-credential"): + continue + if secret_name in expected: + continue + if secret_key is not None and secret_key not in {"host", "port", "username", "password", "endpoint"}: + continue + + marker = (doc.path, secret_name) + if marker in seen: + continue + seen.add(marker) + + expected_list = ", ".join(sorted(expected)) + add_doc_violation( + violations, + rule_id="R037", + doc=doc, + pattern=rf"^\s*name\s*:\s*{re.escape(secret_name)}\s*$", + default_pattern=r"^\s*env\s*:", + message=( + f"PostgreSQL secret reference '{secret_name}' must match the " + f"Cluster metadata.name-derived secret ({expected_list})" + ), + ) + + return violations + + +def _extract_job_script(doc) -> str: + if not isinstance(doc.data, dict): + return "" + template_spec = get_template_spec(doc.data) + if not isinstance(template_spec, dict): + return "" + script_parts: List[str] = [] + containers = template_spec.get("containers") + if not isinstance(containers, list): + return "" + for container in containers: + if not isinstance(container, dict): + continue + for key in ("command", "args"): + value = container.get(key) + if isinstance(value, str): + script_parts.append(value) + continue + if isinstance(value, list): + script_parts.append("\n".join(str(item) for item in value)) + return "\n".join(script_parts) + + +def _script_targets_database(script: str, database_name: str) -> bool: + escaped = re.escape(database_name) + patterns = [ + rf"datname\s*=\s*['\"]{escaped}['\"]", + rf"\bcreatedb\b[\s\\\n\"'\$()\-A-Za-z0-9_./]*\b{escaped}\b", + rf"CREATE\s+DATABASE\s+(?:IF\s+NOT\s+EXISTS\s+)?\"?{escaped}\"?", + ] + return any(re.search(pattern, script, re.IGNORECASE) for pattern in patterns) + + +def _is_robust_pg_init_script(script: str) -> bool: + has_readiness_wait = bool(re.search(r"\bpg_isready\b", script)) or bool(re.search(r"\buntil\s+psql\b", script)) + has_exists_check = bool(re.search(r"SELECT\s+1\s+FROM\s+pg_database", script, re.IGNORECASE)) and ( + "datname=" in script + ) + has_create = bool(re.search(r"\bcreatedb\b", script)) or bool( + re.search(r"CREATE\s+DATABASE", script, re.IGNORECASE) + ) + return has_readiness_wait and has_exists_check and has_create + + +def check_postgres_custom_db_init_job(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + + artifact_docs = [doc for doc in context.yaml_documents if _is_template_artifact_document(doc)] + if not artifact_docs: + return violations + + if not any(_is_postgres_cluster_document(doc) for doc in artifact_docs): + return violations + + required_databases: set[str] = set() + workload_docs = [ + doc + for doc in artifact_docs + if is_app_workload_document(doc) and has_managed_workload_marker(doc.data) + ] + for doc in workload_docs: + required_databases.update(_extract_required_postgres_databases(doc)) + + if not required_databases: + return violations + + job_docs = [doc for doc in artifact_docs if isinstance(doc.data, dict) and doc.data.get("kind") == "Job"] + pg_init_jobs = [] + for doc in job_docs: + metadata = doc.data.get("metadata") if isinstance(doc.data, dict) else None + name = metadata.get("name") if isinstance(metadata, dict) else None + if isinstance(name, str) and "pg-init" in name: + pg_init_jobs.append((doc, _extract_job_script(doc))) + + for database_name in sorted(required_databases): + matching_job = None + for doc, script in pg_init_jobs: + if _script_targets_database(script, database_name): + matching_job = (doc, script) + break + + if matching_job is None: + target_doc = workload_docs[0] if workload_docs else artifact_docs[0] + add_doc_violation( + violations, + rule_id="R027", + doc=target_doc, + pattern=r"postgres(?:ql)?://", + default_pattern=r"^\s*env\s*:", + message=( + f"non-default PostgreSQL database '{database_name}' requires a " + "${{ defaults.app_name }}-pg-init Job in template artifacts" + ), + ) + continue + + job_doc, script = matching_job + if _is_robust_pg_init_script(script): + continue + add_doc_violation( + violations, + rule_id="R027", + doc=job_doc, + pattern=r"^\s*command\s*:", + default_pattern=r"^\s*containers\s*:", + message=( + "pg-init Job for non-default PostgreSQL databases must include readiness wait " + "(for example pg_isready) and idempotent create logic (exists check before create)" + ), + ) + + return violations + + +def _is_worker_args(args: Any) -> bool: + if not isinstance(args, list) or not args: + return False + first = str(args[0]).strip().lower() + return first == "worker" + + +def _probe_has_http_path(probe: Any, expected_path: str) -> bool: + if not isinstance(probe, dict): + return False + http_get = probe.get("httpGet") + if not isinstance(http_get, dict): + return False + if http_get.get("path") != expected_path: + return False + port = http_get.get("port") + return isinstance(port, (int, str)) and bool(str(port).strip()) + + +def _probe_has_exec_command(probe: Any, expected_fragment: str) -> bool: + if not isinstance(probe, dict): + return False + exec_probe = probe.get("exec") + if not isinstance(exec_probe, dict): + return False + command = exec_probe.get("command") + if not isinstance(command, list) or not command: + return False + merged = " ".join(str(item) for item in command) + return expected_fragment in merged + + +def check_official_health_probes(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + if not is_app_workload_document(doc): + continue + if not has_managed_workload_marker(doc.data): + continue + + template_spec = get_template_spec(doc.data) + containers = template_spec.get("containers") if isinstance(template_spec, dict) else None + if not isinstance(containers, list) or not containers: + continue + if not isinstance(containers[0], dict): + continue + container = containers[0] + image = container.get("image") + if not isinstance(image, str) or not image.strip(): + continue + image_lower = image.strip().lower() + + worker_marker = next((m for m in OFFICIAL_HEALTH_WORKER_EXEC_EXPECTATIONS if m in image_lower), None) + if worker_marker and _is_worker_args(container.get("args")): + expected = OFFICIAL_HEALTH_WORKER_EXEC_EXPECTATIONS[worker_marker] + liveness = container.get("livenessProbe") + readiness = container.get("readinessProbe") + startup = container.get("startupProbe") + if not _probe_has_exec_command(liveness, expected["liveness_command"]): + add_doc_violation( + violations, + rule_id="R024", + doc=doc, + pattern=r"^\s*livenessProbe\s*:", + default_pattern=r"^\s*containers\s*:", + message=( + "workloads with official health checks must define livenessProbe; " + "expected exec command containing 'ak healthcheck'" + ), + ) + if not _probe_has_exec_command(readiness, expected["readiness_command"]): + add_doc_violation( + violations, + rule_id="R024", + doc=doc, + pattern=r"^\s*readinessProbe\s*:", + default_pattern=r"^\s*containers\s*:", + message=( + "workloads with official health checks must define readinessProbe; " + "expected exec command containing 'ak healthcheck'" + ), + ) + if not _probe_has_exec_command(startup, expected["startup_command"]): + add_doc_violation( + violations, + rule_id="R024", + doc=doc, + pattern=r"^\s*startupProbe\s*:", + default_pattern=r"^\s*containers\s*:", + message=( + "workloads with slow startup and official health checks must define startupProbe; " + "expected exec command containing 'ak healthcheck'" + ), + ) + continue + + http_marker = next((m for m in OFFICIAL_HEALTH_HTTP_EXPECTATIONS if m in image_lower), None) + if not http_marker: + continue + + expected = OFFICIAL_HEALTH_HTTP_EXPECTATIONS[http_marker] + liveness = container.get("livenessProbe") + readiness = container.get("readinessProbe") + startup = container.get("startupProbe") + if not _probe_has_http_path(liveness, expected["liveness_path"]): + add_doc_violation( + violations, + rule_id="R024", + doc=doc, + pattern=r"^\s*livenessProbe\s*:", + default_pattern=r"^\s*containers\s*:", + message=( + "workloads with official health checks must define livenessProbe " + "with the official endpoint path" + ), + ) + if not _probe_has_http_path(readiness, expected["readiness_path"]): + add_doc_violation( + violations, + rule_id="R024", + doc=doc, + pattern=r"^\s*readinessProbe\s*:", + default_pattern=r"^\s*containers\s*:", + message=( + "workloads with official health checks must define readinessProbe " + "with the official endpoint path" + ), + ) + if not _probe_has_http_path(startup, expected["startup_path"]): + add_doc_violation( + violations, + rule_id="R024", + doc=doc, + pattern=r"^\s*startupProbe\s*:", + default_pattern=r"^\s*containers\s*:", + message=( + "workloads with slow startup and official health checks must define startupProbe " + "with the official endpoint path" + ), + ) + return violations + + +def check_cronjob_required_labels(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + + for doc in iter_documents_by_kind(context, "CronJob"): + metadata = doc.data.get("metadata") if isinstance(doc.data, dict) else None + if not isinstance(metadata, dict): + continue + name = metadata.get("name") + if not isinstance(name, str) or not name.strip(): + continue + + labels = metadata.get("labels") + if not isinstance(labels, dict): + add_doc_violation( + violations, + rule_id="R036", + doc=doc, + pattern=r"^\s*labels\s*:", + default_pattern=r"^\s*metadata\s*:", + message=( + "CronJob metadata.labels must define cloud.sealos.io/cronjob, " + "cronjob-launchpad-name, and cronjob-type" + ), + ) + continue + + cronjob_label_value = labels.get(CRONJOB_LABEL_KEY) + if cronjob_label_value != name: + add_doc_violation( + violations, + rule_id="R036", + doc=doc, + pattern=re.escape(CRONJOB_LABEL_KEY), + default_pattern=r"^\s*labels\s*:", + message="CronJob label cloud.sealos.io/cronjob must exist and exactly match metadata.name", + ) + + for label_key, expected_value in CRONJOB_REQUIRED_LABELS.items(): + if labels.get(label_key) == expected_value: + continue + add_doc_violation( + violations, + rule_id="R036", + doc=doc, + pattern=re.escape(label_key), + default_pattern=r"^\s*labels\s*:", + message=f"CronJob label {label_key} must exist and be set to {expected_value!r}", + ) + + return violations + + +def check_revision_history_limit(context: ScanContext) -> List[Violation]: + return check_managed_workload_setting( + context, + rule_id="R009", + value_extractor=lambda data: data.get("spec", {}).get("revisionHistoryLimit") + if isinstance(data.get("spec"), dict) + else None, + expected=1, + value_pattern=r"^\s*revisionHistoryLimit\s*:", + fallback_pattern=r"^\s*spec\s*:", + missing_message="managed app workloads must explicitly set revisionHistoryLimit: 1", + mismatch_message="revisionHistoryLimit must be set to 1 for managed app workloads", + ) + + +def _extract_automount_service_account_token(data: dict) -> object: + template_spec = get_template_spec(data) + if not isinstance(template_spec, dict): + return None + return template_spec.get("automountServiceAccountToken") + + +def check_automount_service_account_token(context: ScanContext) -> List[Violation]: + return check_managed_workload_setting( + context, + rule_id="R010", + value_extractor=_extract_automount_service_account_token, + expected=False, + value_pattern=r"^\s*automountServiceAccountToken\s*:", + fallback_pattern=r"^\s*template\s*:", + missing_message="managed app workloads must explicitly set automountServiceAccountToken: false", + mismatch_message="automountServiceAccountToken must be false for managed app workloads", + ) + + +def check_image_pull_secret_refs(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + + for doc in context.yaml_documents: + if doc.skip_checks or not is_app_workload_document(doc) or not has_managed_workload_marker(doc.data): + continue + if not isinstance(doc.data, dict): + continue + + template_spec = get_template_spec(doc.data) + image_pull_secrets = template_spec.get("imagePullSecrets") if isinstance(template_spec, dict) else None + + referenced_names: List[str] = [] + if isinstance(image_pull_secrets, list): + for item in image_pull_secrets: + if not isinstance(item, dict): + continue + name = item.get("name") + if isinstance(name, str) and name.strip(): + referenced_names.append(name.strip()) + + if "${{ defaults.app_name }}" in referenced_names: + continue + + add_doc_violation( + violations, + rule_id="R035", + doc=doc, + pattern=r"^\s*imagePullSecrets\s*:", + default_pattern=r"^\s*template\s*:", + message=( + "managed app workloads must reference the app-scoped image pull secret " + "`${{ defaults.app_name }}` via template.spec.imagePullSecrets" + ), + ) + + return violations + + +APP_RULES: Dict[str, Rule] = { + "R001": Rule("R001", check_no_latest_tags), + "R016": Rule("R016", check_no_floating_image_tags), + "R018": Rule("R018", check_no_compose_image_variables), + "R002": Rule("R002", check_app_no_spec_template), + "R003": Rule("R003", check_app_has_spec_data_url), + "R032": Rule("R032", check_app_display_type_normal), + "R033": Rule("R033", check_app_type_link), + "R004": Rule("R004", check_template_name_is_hardcoded_lowercase), + "R012": Rule("R012", check_template_required_metadata_fields), + "R013": Rule("R013", check_template_folder_matches_name), + "R014": Rule("R014", check_template_icon_paths), + "R025": Rule("R025", check_template_readme_paths), + "R021": Rule("R021", check_template_i18n_zh_description_chinese), + "R022": Rule("R022", check_template_i18n_zh_title_absent), + "R023": Rule("R023", check_template_categories_allowed), + "R024": Rule("R024", check_official_health_probes), + "R036": Rule("R036", check_cronjob_required_labels), + "R015": Rule("R015", check_origin_image_name_matches_container), + "R020": Rule("R020", check_service_ports_have_names), + "R029": Rule("R029", check_service_labels_match_selector_app), + "R030": Rule("R030", check_configmap_labels_match_name), + "R031": Rule("R031", check_ingress_name_matches_backends), + "R026": Rule("R026", check_http_ingress_annotations), + "R027": Rule("R027", check_postgres_custom_db_init_job), + "R037": Rule("R037", check_postgres_secret_refs_match_cluster_name), + "R008": Rule("R008", check_deploy_manager_label_match_name), + "R034": Rule("R034", check_app_label_match_name), + "R028": Rule("R028", check_container_names_match_workload_name), + "R009": Rule("R009", check_revision_history_limit), + "R010": Rule("R010", check_automount_service_account_token), + "R035": Rule("R035", check_image_pull_secret_refs), +} diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules_security.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules_security.py new file mode 100644 index 00000000..9b7563a2 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules_security.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +"""Security and secret-handling consistency rules.""" + +from __future__ import annotations + +import re +from typing import Dict, List, Optional, Set + +from check_consistency_models import DB_SECRET_SUFFIXES, Rule, ScanContext, Violation, WORKLOAD_KINDS +from check_consistency_helpers_workload import iter_containers, iter_workload_secret_refs +from check_consistency_parser import find_line + + +APP_NAME_PLACEHOLDER = r"\$\{\{\s*defaults\.app_name\s*\}\}" +SERVICE_ACCOUNT_PLACEHOLDER = r"\$\{\{\s*SEALOS_SERVICE_ACCOUNT\s*\}\}" +APPROVED_DB_SECRET_PATTERN = re.compile( + rf"^{APP_NAME_PLACEHOLDER}(?:{'|'.join(re.escape(suffix) for suffix in DB_SECRET_SUFFIXES)})$" +) +OBJECT_STORAGE_BASE_SECRET_NAME = "object-storage-key" +OBJECT_STORAGE_BUCKET_SECRET_PATTERN = re.compile( + rf"^object-storage-key-{SERVICE_ACCOUNT_PLACEHOLDER}-{APP_NAME_PLACEHOLDER}(?:-[a-z0-9](?:[-a-z0-9]*[a-z0-9])?)*$" +) +OBJECT_STORAGE_BASE_ENV_NAMES: Set[str] = { + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY", + "BACKEND_STORAGE_MINIO_EXTERNAL_ENDPOINT", +} +OBJECT_STORAGE_BUCKET_ENV_NAMES: Set[str] = {"S3_BUCKET"} +DB_CONNECTION_INDICATOR_HINTS: Set[str] = { + "DB", + "DATABASE", + "POSTGRES", + "POSTGRESQL", + "PG", + "MYSQL", + "MARIADB", + "MONGO", + "MONGODB", + "REDIS", + "KAFKA", +} +# These envs contain DB-related tokens (PG/URL/PORT) in names but are not +# direct database connection fields and should not be forced to secretKeyRef. +NON_DB_CONNECTION_ENV_EXACT: Set[str] = { + "STUDIO_PG_META_URL", + "POSTGREST_URL", + "POSTGREST_BASE_URL", + "PGRST_OPENAPI_SERVER_PROXY_URI", + "PG_META_PORT", + "CODE_SANDBOX_URL", + "SANDBOX_URL", +} +ENV_VALUE_REF_RE = re.compile(r"\$\(([A-Za-z_][A-Za-z0-9_]*)\)") +DB_COMPOSABLE_KEYS: Set[str] = {"endpoint", "host", "port", "username", "password"} +REDIS_SERVICE_HOST_TEMPLATE_PATTERN = re.compile( + rf"^{APP_NAME_PLACEHOLDER}-redis-redis(?:-redis)?\.\$\{{\{{\s*SEALOS_NAMESPACE\s*\}}\}}\.svc(?:\.cluster\.local)?$" +) +REDIS_SERVICE_HOST_RUNTIME_PATTERN = re.compile( + r"^[a-z0-9](?:[-a-z0-9]*redis[-a-z0-9]*)\.[a-z0-9](?:[-a-z0-9]*[a-z0-9])?\.svc(?:\.cluster\.local)?$" +) +MONGODB_SERVICE_HOST_TEMPLATE_PATTERN = re.compile( + rf"^{APP_NAME_PLACEHOLDER}-(?:mongo|mongodb)-mongodb\.\$\{{\{{\s*SEALOS_NAMESPACE\s*\}}\}}\.svc(?:\.cluster\.local)?$" +) +MONGODB_SERVICE_HOST_RUNTIME_PATTERN = re.compile( + r"^[a-z0-9](?:[-a-z0-9]*mongo(?:db)?[-a-z0-9]*)-mongodb\.[a-z0-9](?:[-a-z0-9]*[a-z0-9])?\.svc(?:\.cluster\.local)?$" +) + + +def is_approved_db_secret_name(secret_name: str) -> bool: + return APPROVED_DB_SECRET_PATTERN.fullmatch(secret_name) is not None + + +def is_approved_object_storage_secret_ref(source: str, secret_name: str, env_name: Optional[str]) -> bool: + if source != "env" or not isinstance(env_name, str): + return False + if secret_name == OBJECT_STORAGE_BASE_SECRET_NAME: + return env_name in OBJECT_STORAGE_BASE_ENV_NAMES + if OBJECT_STORAGE_BUCKET_SECRET_PATTERN.fullmatch(secret_name): + normalized_env = normalize_env_name(env_name) + return normalized_env in OBJECT_STORAGE_BUCKET_ENV_NAMES or normalized_env.endswith("_BUCKET") + return False + + +def normalize_env_name(env_name: str) -> str: + return re.sub(r"[^A-Z0-9]+", "_", env_name.upper()) + + +def infer_db_connection_field(env_name: str) -> Optional[str]: + upper = normalize_env_name(env_name) + if upper in NON_DB_CONNECTION_ENV_EXACT: + return None + if not any(hint in upper for hint in DB_CONNECTION_INDICATOR_HINTS): + return None + + if re.search(r"(?:^|_)(?:PASSWORD|PASS|PWD)(?:$|_)", upper): + return "password" + if re.search(r"(?:^|_)(?:USERNAME|USER)(?:$|_)", upper): + return "username" + if re.search(r"(?:^|_)(?:ENDPOINT|URI|URL|DSN)(?:$|_)", upper): + return "endpoint" + if re.search(r"(?:^|_)(?:HOST|SERVER)(?:$|_)", upper): + return "host" + if re.search(r"(?:^|_)(?:PORT)(?:$|_)", upper): + return "port" + + return None + + +def extract_secret_ref(env_item: Dict[str, object]) -> Optional[Dict[str, str]]: + value_from = env_item.get("valueFrom") + secret_ref = value_from.get("secretKeyRef") if isinstance(value_from, dict) else None + if not isinstance(secret_ref, dict): + return None + name = secret_ref.get("name") + key = secret_ref.get("key") + if not isinstance(name, str) or not isinstance(key, str): + return None + return {"name": name, "key": key} + + +def is_composed_db_endpoint_from_secret( + env_item: Dict[str, object], + env_items_by_name: Dict[str, Dict[str, object]], +) -> bool: + value = env_item.get("value") + if not isinstance(value, str): + return False + + ref_names = ENV_VALUE_REF_RE.findall(value) + if not ref_names: + return False + + has_endpoint = False + has_host = False + has_port = False + for ref_name in ref_names: + ref_env = env_items_by_name.get(ref_name) + if not isinstance(ref_env, dict): + return False + ref_secret = extract_secret_ref(ref_env) + if ref_secret is None: + return False + if not is_approved_db_secret_name(ref_secret["name"]): + return False + ref_key = ref_secret["key"] + if ref_key not in DB_COMPOSABLE_KEYS: + return False + if ref_key == "endpoint": + has_endpoint = True + if ref_key == "host": + has_host = True + if ref_key == "port": + has_port = True + + return has_endpoint or (has_host and has_port) + + +def resolve_env_value(value: object, env_items_by_name: Dict[str, Dict[str, object]], depth: int = 0) -> Optional[str]: + if not isinstance(value, str): + return None + if depth > 4: + return None + + ref_match = re.fullmatch(r"\$\(([A-Za-z_][A-Za-z0-9_]*)\)", value.strip()) + if not ref_match: + return value + + ref_env = env_items_by_name.get(ref_match.group(1)) + if not isinstance(ref_env, dict): + return None + return resolve_env_value(ref_env.get("value"), env_items_by_name, depth + 1) + + +def is_redis_service_host(value: str) -> bool: + stripped = value.strip() + return ( + REDIS_SERVICE_HOST_TEMPLATE_PATTERN.fullmatch(stripped) is not None + or REDIS_SERVICE_HOST_RUNTIME_PATTERN.fullmatch(stripped) is not None + ) + + +def is_redis_service_port(value: str) -> bool: + return value.strip() == "6379" + + +def is_mongodb_service_host(value: str) -> bool: + stripped = value.strip() + return ( + MONGODB_SERVICE_HOST_TEMPLATE_PATTERN.fullmatch(stripped) is not None + or MONGODB_SERVICE_HOST_RUNTIME_PATTERN.fullmatch(stripped) is not None + ) + + +def is_mongodb_service_port(value: str) -> bool: + return value.strip() == "27017" + + +def is_redis_host_from_env( + env_item: Dict[str, object], + env_items_by_name: Dict[str, Dict[str, object]], +) -> bool: + resolved = resolve_env_value(env_item.get("value"), env_items_by_name) + return isinstance(resolved, str) and is_redis_service_host(resolved) + + +def is_redis_port_from_env( + env_item: Dict[str, object], + env_items_by_name: Dict[str, Dict[str, object]], +) -> bool: + resolved = resolve_env_value(env_item.get("value"), env_items_by_name) + return isinstance(resolved, str) and is_redis_service_port(resolved) + + +def is_redis_password_from_secret( + env_item: Dict[str, object], + env_items_by_name: Dict[str, Dict[str, object]], +) -> bool: + ref_match = re.fullmatch(r"\$\(([A-Za-z_][A-Za-z0-9_]*)\)", str(env_item.get("value", "")).strip()) + if ref_match is None: + return False + ref_env = env_items_by_name.get(ref_match.group(1)) + if not isinstance(ref_env, dict): + return False + ref_secret = extract_secret_ref(ref_env) + if ref_secret is None: + return False + return is_approved_db_secret_name(ref_secret["name"]) and ref_secret["key"] == "password" + + +def is_composed_redis_endpoint_from_service( + env_item: Dict[str, object], + env_items_by_name: Dict[str, Dict[str, object]], +) -> bool: + value = env_item.get("value") + if not isinstance(value, str): + return False + if not value.startswith("redis://"): + return False + + ref_names = ENV_VALUE_REF_RE.findall(value) + has_host = False + has_port = False + + for ref_name in ref_names: + ref_env = env_items_by_name.get(ref_name) + if not isinstance(ref_env, dict): + return False + + ref_secret = extract_secret_ref(ref_env) + if ref_secret is not None: + if not is_approved_db_secret_name(ref_secret["name"]): + return False + ref_key = ref_secret["key"] + if ref_key == "endpoint": + has_host = True + has_port = True + elif ref_key == "host": + has_host = True + elif ref_key == "port": + has_port = True + elif ref_key in {"username", "password"}: + pass + else: + return False + continue + + resolved = resolve_env_value(ref_env.get("value"), env_items_by_name) + if not isinstance(resolved, str): + return False + if is_redis_service_host(resolved): + has_host = True + continue + if is_redis_service_port(resolved): + has_port = True + continue + normalized_ref = normalize_env_name(ref_name) + if normalized_ref.endswith("PASSWORD") or normalized_ref.endswith("USERNAME"): + continue + return False + + if not ref_names: + match = re.search(r"redis://(?::[^@]+@)?([^/:]+):([0-9]+)", value) + if match is None: + return False + return is_redis_service_host(match.group(1)) and is_redis_service_port(match.group(2)) + + return has_host and has_port + + +def is_composed_mongodb_endpoint_from_service( + env_item: Dict[str, object], + env_items_by_name: Dict[str, Dict[str, object]], +) -> bool: + value = env_item.get("value") + if not isinstance(value, str): + return False + if not value.startswith("mongodb://"): + return False + + ref_names = ENV_VALUE_REF_RE.findall(value) + has_host = False + has_port = False + + match = re.search(r"mongodb://(?:[^:@]+(?::[^@]+)?@)?([^/:?]+):([0-9]+)", value) + if match is not None: + if is_mongodb_service_host(match.group(1)): + has_host = True + if is_mongodb_service_port(match.group(2)): + has_port = True + + for ref_name in ref_names: + ref_env = env_items_by_name.get(ref_name) + if not isinstance(ref_env, dict): + return False + + ref_secret = extract_secret_ref(ref_env) + if ref_secret is not None: + if not is_approved_db_secret_name(ref_secret["name"]): + return False + ref_key = ref_secret["key"] + if ref_key == "endpoint": + has_host = True + has_port = True + elif ref_key == "host": + has_host = True + elif ref_key == "port": + has_port = True + elif ref_key in {"username", "password"}: + pass + else: + return False + continue + + resolved = resolve_env_value(ref_env.get("value"), env_items_by_name) + if not isinstance(resolved, str): + return False + if is_mongodb_service_host(resolved): + has_host = True + continue + if is_mongodb_service_port(resolved): + has_port = True + continue + normalized_ref = normalize_env_name(ref_name) + if normalized_ref.endswith("PASSWORD") or normalized_ref.endswith("USERNAME"): + continue + return False + + return has_host and has_port + + +def is_allowed_redis_service_env( + env_name: str, + expected_key: str, + env_item: Dict[str, object], + env_items_by_name: Dict[str, Dict[str, object]], +) -> bool: + normalized = normalize_env_name(env_name) + if "REDIS" not in normalized: + return False + + if expected_key == "host": + return is_redis_host_from_env(env_item, env_items_by_name) + if expected_key == "port": + return is_redis_port_from_env(env_item, env_items_by_name) + if expected_key == "password": + return is_redis_password_from_secret(env_item, env_items_by_name) + if expected_key == "endpoint": + return is_composed_redis_endpoint_from_service(env_item, env_items_by_name) + return False + + +def _find_secret_ref_line(doc, source: str, secret_name: str, env_name: Optional[str]) -> int: + if source == "env" and isinstance(env_name, str): + return find_line(doc, rf"^\s*-\s*name\s*:\s*{re.escape(env_name)}\s*$") + if source == "volume": + return find_line(doc, rf"^\s*secretName\s*:\s*{re.escape(secret_name)}\s*$") + return find_line(doc, rf"^\s*name\s*:\s*{re.escape(secret_name)}\s*$") + + +def _collect_reserved_db_secret_overrides(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + if doc.data.get("kind") != "Secret": + continue + + metadata = doc.data.get("metadata") + secret_name = metadata.get("name") if isinstance(metadata, dict) else None + if not isinstance(secret_name, str) or not is_approved_db_secret_name(secret_name): + continue + + line = find_line( + doc, + rf"^\s*name\s*:\s*{re.escape(secret_name)}\s*$", + default=find_line(doc, r"^\s*metadata\s*:"), + ) + violations.append( + Violation( + rule_id="R007", + path=doc.path, + line=line, + message=( + "database secret names managed by Kubeblocks are reserved; " + "do not define custom Secret resources with those names" + ), + ) + ) + return violations + + +def check_business_env_secret_policy(context: ScanContext) -> List[Violation]: + violations: List[Violation] = _collect_reserved_db_secret_overrides(context) + + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + kind = doc.data.get("kind") + if kind not in WORKLOAD_KINDS: + continue + + for source, secret_name, env_name, _ in iter_workload_secret_refs(doc.data): + if is_approved_db_secret_name(secret_name) or is_approved_object_storage_secret_ref( + source, secret_name, env_name + ): + continue + + line = _find_secret_ref_line(doc, source, secret_name, env_name) + violations.append( + Violation( + rule_id="R007", + path=doc.path, + line=line, + message=( + "business workload secret references must not use custom secrets unless they reference " + "an approved database or object storage secret" + ), + ) + ) + + return violations + + +def check_db_connection_env_secret_requirements(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + if doc.data.get("kind") not in WORKLOAD_KINDS: + continue + + for container in iter_containers(doc.data): + env_list = container.get("env") + if not isinstance(env_list, list): + continue + + env_items_by_name: Dict[str, Dict[str, object]] = {} + for env_item in env_list: + if not isinstance(env_item, dict): + continue + env_name = env_item.get("name") + if isinstance(env_name, str) and env_name not in env_items_by_name: + env_items_by_name[env_name] = env_item + + for env_item in env_list: + if not isinstance(env_item, dict): + continue + env_name = env_item.get("name") + if not isinstance(env_name, str): + continue + + expected_key = infer_db_connection_field(env_name) + if expected_key is None: + continue + + secret_ref = extract_secret_ref(env_item) + if secret_ref is None: + if is_allowed_redis_service_env(env_name, expected_key, env_item, env_items_by_name): + continue + if expected_key == "endpoint" and is_composed_mongodb_endpoint_from_service( + env_item, env_items_by_name + ): + continue + if expected_key == "endpoint" and is_composed_db_endpoint_from_secret( + env_item, env_items_by_name + ): + continue + line = find_line(doc, rf"^\s*-\s*name\s*:\s*{re.escape(env_name)}\s*$") + violations.append( + Violation( + rule_id="R017", + path=doc.path, + line=line, + message=( + "database connection env fields (endpoint/host/port/username/password) " + "must use valueFrom.secretKeyRef" + ), + ) + ) + continue + + secret_name = secret_ref["name"] + if not is_approved_db_secret_name(secret_name): + # Let R007 report unapproved/invalid secret references. + continue + + secret_key = secret_ref["key"] + if secret_key != expected_key: + line = find_line(doc, rf"^\s*-\s*name\s*:\s*{re.escape(env_name)}\s*$") + violations.append( + Violation( + rule_id="R017", + path=doc.path, + line=line, + message=( + f"database env '{env_name}' must use secret key '{expected_key}' " + "from an approved database secret" + ), + ) + ) + + return violations + + +SECURITY_RULES: Dict[str, Rule] = { + "R007": Rule("R007", check_business_env_secret_policy), + "R017": Rule("R017", check_db_connection_env_secret_requirements), +} diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules_storage.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules_storage.py new file mode 100644 index 00000000..001b6db7 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_rules_storage.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Storage and workload-runtime consistency rules.""" + +from __future__ import annotations + +import re +from typing import Dict, List + +from check_consistency_models import ( + DB_COMPONENT_RESOURCE_LIMITS, + DB_COMPONENT_RESOURCE_REQUESTS, + MAX_PVC_STORAGE_BYTES, + Rule, + ScanContext, + Violation, +) +from check_consistency_parser import find_line +from check_consistency_helpers_violations import add_doc_violation +from check_consistency_helpers_storage import ( + contains_key, + has_variable_expression, + iter_pvc_storage_values, + parse_storage_bytes, +) +from check_consistency_helpers_workload import iter_containers + + +def check_no_emptydir(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks: + continue + if contains_key(doc.data, "emptyDir"): + add_doc_violation( + violations, + rule_id="R005", + doc=doc, + pattern=r"^\s*emptyDir\s*:", + message="emptyDir is not allowed; use persistent storage", + ) + return violations + + +def check_image_pull_policy(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks: + continue + for container in iter_containers(doc.data): + image = container.get("image") + if not isinstance(image, str) or not image.strip(): + continue + pull_policy = container.get("imagePullPolicy") + if pull_policy != "IfNotPresent": + line = find_line(doc, r"^\s*imagePullPolicy\s*:", default=find_line(doc, r"^\s*image\s*:")) + message = ( + "container imagePullPolicy must be IfNotPresent" + if pull_policy is not None + else "container must explicitly set imagePullPolicy: IfNotPresent" + ) + violations.append(Violation(rule_id="R006", path=doc.path, line=line, message=message)) + return violations + + +def check_pvc_storage_limit(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks: + continue + + for raw_storage in iter_pvc_storage_values(doc.data): + storage_text = str(raw_storage).strip() + line = find_line( + doc, + rf"^\s*storage\s*:\s*['\"]?{re.escape(storage_text)}['\"]?\s*$", + default=find_line(doc, r"^\s*storage\s*:"), + ) + + if has_variable_expression(storage_text): + violations.append( + Violation( + rule_id="R011", + path=doc.path, + line=line, + message="PVC storage must be a concrete quantity (variables are not allowed)", + ) + ) + continue + + storage_bytes = parse_storage_bytes(storage_text) + if storage_bytes is None: + violations.append( + Violation( + rule_id="R011", + path=doc.path, + line=line, + message=f"unable to parse PVC storage quantity: {storage_text!r}", + ) + ) + continue + + if storage_bytes > MAX_PVC_STORAGE_BYTES: + violations.append( + Violation( + rule_id="R011", + path=doc.path, + line=line, + message="PVC storage request must be <= 1Gi", + ) + ) + + return violations + + +def check_database_cluster_component_resources(context: ScanContext) -> List[Violation]: + violations: List[Violation] = [] + for doc in context.yaml_documents: + if doc.skip_checks or not isinstance(doc.data, dict): + continue + if doc.path.name != "index.yaml": + continue + if doc.data.get("kind") != "Cluster": + continue + + metadata = doc.data.get("metadata") + labels = metadata.get("labels") if isinstance(metadata, dict) else None + db_label = labels.get("kb.io/database") if isinstance(labels, dict) else None + if not isinstance(db_label, str) or not db_label.strip(): + continue + + spec = doc.data.get("spec") + component_specs = spec.get("componentSpecs") if isinstance(spec, dict) else None + if not isinstance(component_specs, list): + continue + + for component in component_specs: + if not isinstance(component, dict): + continue + component_name = str(component.get("name", "")) + resources = component.get("resources") + if not isinstance(resources, dict): + line = find_line( + doc, + rf"^\s*name\s*:\s*{re.escape(component_name)}\s*$", + default=find_line(doc, r"^\s*componentSpecs\s*:"), + ) + violations.append( + Violation( + rule_id="R019", + path=doc.path, + line=line, + message=f"database component {component_name} must define resources limits/requests", + ) + ) + continue + + expected_sections = ( + ("limits", DB_COMPONENT_RESOURCE_LIMITS), + ("requests", DB_COMPONENT_RESOURCE_REQUESTS), + ) + for section_name, expected_values in expected_sections: + section = resources.get(section_name) + if not isinstance(section, dict): + line = find_line( + doc, + rf"^\s*name\s*:\s*{re.escape(component_name)}\s*$", + default=find_line(doc, r"^\s*resources\s*:"), + ) + violations.append( + Violation( + rule_id="R019", + path=doc.path, + line=line, + message=f"database component {component_name} must define resources.{section_name}", + ) + ) + continue + + for key, expected in expected_values.items(): + actual = section.get(key) + if actual == expected: + continue + line = find_line( + doc, + rf"^\s*{re.escape(key)}\s*:\s*['\"]?{re.escape(str(actual))}['\"]?\s*$", + default=find_line( + doc, + rf"^\s*name\s*:\s*{re.escape(component_name)}\s*$", + default=find_line(doc, r"^\s*resources\s*:"), + ), + ) + violations.append( + Violation( + rule_id="R019", + path=doc.path, + line=line, + message=( + f"database component {component_name} resources.{section_name}.{key} " + f"must be {expected}" + ), + ) + ) + + return violations + + +STORAGE_RULES: Dict[str, Rule] = { + "R005": Rule("R005", check_no_emptydir), + "R006": Rule("R006", check_image_pull_policy), + "R011": Rule("R011", check_pvc_storage_limit), + "R019": Rule("R019", check_database_cluster_component_resources), +} diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_runner.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_runner.py new file mode 100644 index 00000000..cfbeedf5 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_consistency_runner.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Orchestrates registry-aware consistency checks.""" + +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional, Sequence + +from check_consistency_context import ContextBuilder +from check_consistency_engine import RuleEngine +from check_consistency_models import Violation +from check_consistency_registry import validate_registry +from check_consistency_rule_registry import REGISTERED_RULES + + +def run_checks( + skill_path: Path, + references_dir: Path, + registry_path: Path, + only_rules: Optional[Sequence[str]] = None, + additional_include_paths: Optional[Sequence[str]] = None, +) -> List[Violation]: + config = validate_registry(registry_path, REGISTERED_RULES.keys()) + include_paths = list(config.include_paths) + if additional_include_paths: + include_paths.extend(additional_include_paths) + builder = ContextBuilder( + skill_path=skill_path, + references_dir=references_dir, + include_paths=include_paths, + ) + context, parse_violations = builder.build() + + engine = RuleEngine( + config=config, + registered_rules=REGISTERED_RULES, + skill_root=skill_path.parent, + ) + selected_rules = engine.resolve_rules(only_rules) + return engine.run( + context=context, + parse_violations=parse_violations, + selected_rules=selected_rules, + ) diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_must_coverage.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_must_coverage.py new file mode 100644 index 00000000..6682e32d --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/check_must_coverage.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Validate coverage mapping between SKILL MUST bullets and enforcement rules.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import Dict, List, Mapping, Sequence, Set, Tuple + +import yaml + + +MUST_SECTION_START = "## MUST Rules (Condensed)" +MUST_SECTION_END = "## Validation Commands" +ALLOWED_ENFORCEMENT_TYPES = {"rule", "manual"} + + +def normalize_line(text: str) -> str: + return re.sub(r"\s+", " ", text.strip()) + + +def extract_must_bullets(skill_text: str) -> List[str]: + if MUST_SECTION_START not in skill_text or MUST_SECTION_END not in skill_text: + raise ValueError("unable to locate MUST section boundaries in SKILL.md") + + start = skill_text.index(MUST_SECTION_START) + end = skill_text.index(MUST_SECTION_END, start) + section = skill_text[start:end] + + bullets: List[str] = [] + for raw_line in section.splitlines(): + match = re.match(r"^\s*-\s+(.+?)\s*$", raw_line) + if match is None: + continue + bullet = normalize_line(match.group(1)) + if not bullet or bullet.endswith(":"): + continue + bullets.append(bullet) + + return bullets + + +def load_rule_ids(rules_file: Path) -> Set[str]: + data = yaml.safe_load(rules_file.read_text(encoding="utf-8")) + if not isinstance(data, dict) or not isinstance(data.get("rules"), list): + raise ValueError(f"invalid rules registry format: {rules_file}") + + ids: Set[str] = set() + for item in data["rules"]: + if not isinstance(item, dict) or not isinstance(item.get("id"), str): + raise ValueError(f"invalid rule entry in registry: {item!r}") + ids.add(item["id"]) + return ids + + +def load_must_mapping(mapping_file: Path) -> Dict[str, Mapping[str, str]]: + data = yaml.safe_load(mapping_file.read_text(encoding="utf-8")) + if not isinstance(data, dict) or not isinstance(data.get("must_rules"), list): + raise ValueError(f"invalid must-rules mapping format: {mapping_file}") + + entries: Dict[str, Mapping[str, str]] = {} + for item in data["must_rules"]: + if not isinstance(item, dict): + raise ValueError(f"invalid must-rules entry: {item!r}") + + must = item.get("must") + enforcement = item.get("enforcement") + if not isinstance(must, str) or not isinstance(enforcement, dict): + raise ValueError(f"invalid must-rules entry: {item!r}") + + must_key = normalize_line(must) + if must_key in entries: + raise ValueError(f"duplicate must mapping entry: {must}") + + enforcement_type = enforcement.get("type") + target = enforcement.get("target") + note = enforcement.get("note") + + if enforcement_type not in ALLOWED_ENFORCEMENT_TYPES: + allowed = ", ".join(sorted(ALLOWED_ENFORCEMENT_TYPES)) + raise ValueError(f"invalid enforcement type for must entry {must!r}: {enforcement_type!r} (allowed: {allowed})") + if enforcement_type == "rule" and not isinstance(target, str): + raise ValueError(f"rule enforcement must define string target for must entry: {must!r}") + if enforcement_type == "manual" and not isinstance(note, str): + raise ValueError(f"manual enforcement must define note for must entry: {must!r}") + + entries[must_key] = { + "type": enforcement_type, + "target": str(target) if isinstance(target, str) else "", + "note": str(note) if isinstance(note, str) else "", + } + + return entries + + +def validate_must_coverage(skill_file: Path, mapping_file: Path, rules_file: Path) -> List[str]: + errors: List[str] = [] + + bullets = extract_must_bullets(skill_file.read_text(encoding="utf-8")) + bullet_keys = [normalize_line(item) for item in bullets] + mapping = load_must_mapping(mapping_file) + rule_ids = load_rule_ids(rules_file) + + missing = [item for item in bullet_keys if item not in mapping] + extras = [item for item in mapping.keys() if item not in set(bullet_keys)] + + if missing: + errors.append("missing MUST mappings:") + errors.extend([f" - {item}" for item in missing]) + + if extras: + errors.append("stale MUST mappings (not found in SKILL.md MUST section):") + errors.extend([f" - {item}" for item in extras]) + + for must_text, enforcement in mapping.items(): + if enforcement["type"] != "rule": + continue + target = enforcement["target"] + if target not in rule_ids: + errors.append(f"rule mapping points to undefined rule id: {target} (must: {must_text})") + + return errors + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate MUST bullet coverage mapping") + parser.add_argument("--skill", default="SKILL.md", help="Path to SKILL.md") + parser.add_argument( + "--mapping", + default="references/must-rules-map.yaml", + help="Path to MUST coverage mapping file", + ) + parser.add_argument( + "--rules-file", + default="references/rules-registry.yaml", + help="Path to machine-readable rules registry", + ) + return parser.parse_args(argv) + + +def resolve_path(value: str, base: Path) -> Path: + path = Path(value) + if path.is_absolute(): + return path + return (base / path).resolve() + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + skill_file = Path(args.skill).resolve() + skill_root = skill_file.parent + mapping_file = resolve_path(args.mapping, skill_root) + rules_file = resolve_path(args.rules_file, skill_root) + + for required in (skill_file, mapping_file, rules_file): + if not required.exists(): + print(f"ERROR: file not found: {required}") + return 2 + + try: + errors = validate_must_coverage(skill_file, mapping_file, rules_file) + except ValueError as exc: + print(f"ERROR: {exc}") + return 2 + + if errors: + print("MUST coverage check failed:") + for err in errors: + print(f"- {err}") + return 1 + + print("MUST coverage check passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/compose_to_template.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/compose_to_template.py new file mode 100644 index 00000000..ae7e2cb3 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/compose_to_template.py @@ -0,0 +1,2300 @@ +#!/usr/bin/env python3 +"""Deterministic Docker Compose -> Sealos template converter.""" + +from __future__ import annotations + +import argparse +import math +import os +import re +import shlex +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple +from urllib.parse import urlparse + +import yaml + +from path_converter import path_to_vn_name + + +DB_TYPE_PATTERNS: Dict[str, Tuple[str, ...]] = { + "postgres": ("postgres", "postgresql"), + "mysql": ("mysql", "mariadb"), + "mongodb": ("mongo", "mongodb"), + "redis": ("redis",), + "kafka": ("kafka",), +} +SPECIAL_DB_RESOURCE_TYPES = {"postgres", "mysql", "mongodb", "redis", "kafka"} +EDGE_GATEWAY_SERVICE_HINTS: Tuple[str, ...] = ("traefik",) +EDGE_GATEWAY_IMAGE_HINTS: Tuple[str, ...] = ("traefik",) +EDGE_GATEWAY_PORT_HINTS = {80, 443} +EDGE_GATEWAY_COMMAND_HINTS: Tuple[str, ...] = ( + "--entrypoints.", + "--providers.", + "--api.dashboard", + "--ping", + "traefik", +) + +DB_FQDN_BY_TYPE: Dict[str, str] = { + "postgres": "${{ defaults.app_name }}-pg-postgresql.${{ SEALOS_NAMESPACE }}.svc.cluster.local", + "mysql": "${{ defaults.app_name }}-mysql-mysql.${{ SEALOS_NAMESPACE }}.svc.cluster.local", + "mongodb": "${{ defaults.app_name }}-mongo-mongodb.${{ SEALOS_NAMESPACE }}.svc.cluster.local", + "redis": "${{ defaults.app_name }}-redis-redis-redis.${{ SEALOS_NAMESPACE }}.svc.cluster.local", + "kafka": "${{ defaults.app_name }}-broker-kafka.${{ SEALOS_NAMESPACE }}.svc.cluster.local", +} +DB_SECRET_NAME_BY_TYPE: Dict[str, str] = { + "postgres": "${{ defaults.app_name }}-pg-conn-credential", + "mysql": "${{ defaults.app_name }}-mysql-conn-credential", + "mongodb": "${{ defaults.app_name }}-mongo-mongodb-account-root", + "redis": "${{ defaults.app_name }}-redis-redis-account-default", + "kafka": "${{ defaults.app_name }}-broker-account-admin", +} +DB_ENV_HINTS_BY_TYPE: Dict[str, Tuple[str, ...]] = { + "postgres": ("POSTGRES", "POSTGRESQL", "PG"), + "mysql": ("MYSQL", "MARIADB"), + "mongodb": ("MONGO", "MONGODB"), + "redis": ("REDIS",), + "kafka": ("KAFKA",), +} + +OBJECT_STORAGE_BASE_ENV_NAMES = { + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY", + "BACKEND_STORAGE_MINIO_EXTERNAL_ENDPOINT", +} +OBJECT_STORAGE_BUCKET_ENV_NAME = "S3_BUCKET" +COMPOSE_REFERENCE_RE = re.compile(r"\$\{[^}]+\}") +INVALID_NAME_RE = re.compile(r"[^a-z0-9]+") +MODE_SUFFIXES = {"ro", "rw", "z", "Z", "cached", "delegated", "consistent"} +TLS_TERMINATION_PORT = 443 +TLS_CERT_DIR_NAMES = {"ssl", "cert", "certs", "tls"} +TLS_CERT_MOUNT_EXACT_PATHS = { + "/etc/nginx/ssl", + "/etc/ssl", + "/etc/certs", + "/etc/tls", + "/ssl", + "/certs", + "/tls", +} +EXPLICIT_VERSION_TAG_RE = re.compile( + r"^v?(?P\d+)\.(?P\d+)\.(?P\d+)(?:[-+](?P[0-9A-Za-z][0-9A-Za-z._-]*))?$" +) +FLOATING_NUMERIC_TAG_RE = re.compile(r"^v?\d+(?:\.\d+)?$") +FLOATING_ALIAS_TAGS = {"latest", "stable", "main", "master", "edge", "nightly", "dev"} +COMPOSE_BRACED_VAR_RE = re.compile(r"\$\{([^}]+)\}") +COMPOSE_SIMPLE_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)") +DEFAULT_RESOURCE_LIMITS = {"cpu": "200m", "memory": "256Mi"} +DEFAULT_RESOURCE_REQUESTS = {"cpu": "20m", "memory": "25Mi"} +DB_COMPONENT_RESOURCE_LIMITS = {"cpu": "500m", "memory": "512Mi"} +DB_COMPONENT_RESOURCE_REQUESTS = {"cpu": "50m", "memory": "51Mi"} +ZH_CHAR_RE = re.compile(r"[\u3400-\u4DBF\u4E00-\u9FFF]") +EN_DESCRIPTION_REWRITE_PATTERNS: Tuple[Tuple[re.Pattern[str], str], ...] = ( + ( + re.compile( + r"\bopen[- ]source identity and access management platform for authentication and authorization\b" + ), + "开源身份与访问管理平台,提供认证与授权能力", + ), +) +EN_DESCRIPTION_TERM_REPLACEMENTS: Tuple[Tuple[str, str], ...] = ( + ("identity and access management", "身份与访问管理"), + ("authentication and authorization", "认证与授权"), + ("open-source", "开源"), + ("open source", "开源"), + ("self-hosted", "可自托管"), + ("platform", "平台"), + ("service", "服务"), + ("application", "应用"), + ("tool", "工具"), + ("database", "数据库"), + ("monitoring", "监控"), + ("analytics", "分析"), + ("authentication", "认证"), + ("authorization", "授权"), + ("for", "用于"), + ("with", "支持"), + ("and", "与"), +) +ALLOWED_TEMPLATE_CATEGORIES = { + "tool", + "ai", + "game", + "database", + "low-code", + "monitor", + "dev-ops", + "blog", + "storage", + "frontend", + "backend", +} +CATEGORY_ALIASES = { + "security": "backend", + "devops": "dev-ops", + "dev-ops": "dev-ops", + "dev_ops": "dev-ops", + "ml": "ai", + "machine-learning": "ai", +} +TEMPLATE_README_BASE = "https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template" +HTTP_INGRESS_ANNOTATIONS = { + "kubernetes.io/ingress.class": "nginx", + "nginx.ingress.kubernetes.io/proxy-body-size": "32m", + "nginx.ingress.kubernetes.io/server-snippet": ( + "client_header_buffer_size 64k;\n" + "large_client_header_buffers 4 128k;" + ), + "nginx.ingress.kubernetes.io/ssl-redirect": "true", + "nginx.ingress.kubernetes.io/backend-protocol": "HTTP", + "nginx.ingress.kubernetes.io/client-body-buffer-size": "64k", + "nginx.ingress.kubernetes.io/proxy-buffer-size": "64k", + "nginx.ingress.kubernetes.io/proxy-send-timeout": "300", + "nginx.ingress.kubernetes.io/proxy-read-timeout": "300", + "nginx.ingress.kubernetes.io/configuration-snippet": ( + "if ($request_uri ~* \\.(js|css|gif|jpe?g|png)) {\n" + " expires 30d;\n" + " add_header Cache-Control \"public\";\n" + "}" + ), +} +COMPOSE_DURATION_PART_RE = re.compile(r"(\d+)(ns|us|ms|s|m|h)") +URL_IN_COMMAND_RE = re.compile(r"https?://[^\s\"'`]+") + +OFFICIAL_HEALTH_HTTP_PROFILES: Dict[str, Dict[str, Any]] = { + "goauthentik/server": { + "liveness_path": "/-/health/live/", + "readiness_path": "/-/health/ready/", + "startup_path": "/-/health/ready/", + "preferred_port": 9000, + "scheme": "HTTP", + "initialDelaySeconds": 30, + "periodSeconds": 10, + "timeoutSeconds": 5, + "failureThreshold": 6, + "startupPeriodSeconds": 10, + "startupTimeoutSeconds": 5, + "startupFailureThreshold": 90, + } +} +OFFICIAL_HEALTH_WORKER_PROFILES: Dict[str, Dict[str, Any]] = { + "goauthentik/server": { + "command": ["sh", "-c", "ak healthcheck"], + "startup_command": ["sh", "-c", "ak healthcheck"], + "initialDelaySeconds": 30, + "periodSeconds": 10, + "timeoutSeconds": 5, + "failureThreshold": 6, + "startupPeriodSeconds": 10, + "startupTimeoutSeconds": 5, + "startupFailureThreshold": 90, + } +} + + +@dataclass(frozen=True) +class MetadataOptions: + app_name: str + title: str + description: str + url: str + git_repo: str + author: str + categories: Sequence[str] + repo_raw_base: str + + +@dataclass(frozen=True) +class ServiceShape: + ports: Tuple[int, ...] + mount_paths: Tuple[str, ...] + + +def db_component_resources() -> Dict[str, Dict[str, str]]: + return { + "limits": dict(DB_COMPONENT_RESOURCE_LIMITS), + "requests": dict(DB_COMPONENT_RESOURCE_REQUESTS), + } + + +def normalize_k8s_name(raw: str) -> str: + value = INVALID_NAME_RE.sub("-", raw.strip().lower()).strip("-") + if not value: + raise ValueError(f"unable to derive a valid name from: {raw!r}") + return value + + +def has_pinned_image(image: str) -> bool: + text = image.strip() + if not text: + return False + if "@sha256:" in text: + return True + without_digest = text.split("@", 1)[0] + last_segment = without_digest.rsplit("/", 1)[-1] + return ":" in last_segment + + +def is_latest_image(image: str) -> bool: + without_digest = image.strip().split("@", 1)[0] + last_segment = without_digest.rsplit("/", 1)[-1] + if ":" not in last_segment: + return False + tag = last_segment.rsplit(":", 1)[-1].lower() + return tag == "latest" + + +def split_image_reference(image: str) -> Tuple[str, Optional[str], Optional[str]]: + text = image.strip() + digest: Optional[str] = None + if "@" in text: + text, digest = text.split("@", 1) + last_slash = text.rfind("/") + last_colon = text.rfind(":") + if last_colon > last_slash: + return text[:last_colon], text[last_colon + 1 :], digest + return text, None, digest + + +def is_explicit_version_tag(tag: str) -> bool: + return EXPLICIT_VERSION_TAG_RE.fullmatch(tag.strip()) is not None + + +def is_floating_tag(tag: str) -> bool: + normalized = tag.strip().lower() + if normalized in FLOATING_ALIAS_TAGS: + return True + return FLOATING_NUMERIC_TAG_RE.fullmatch(normalized) is not None + + +def _version_sort_key(tag: str) -> Tuple[int, int, int, int, str]: + match = EXPLICIT_VERSION_TAG_RE.fullmatch(tag.strip()) + if match is None: + raise ValueError(f"not an explicit version tag: {tag}") + suffix = match.group("suffix") or "" + is_stable = 1 if not suffix else 0 + return ( + int(match.group("major")), + int(match.group("minor")), + int(match.group("patch")), + is_stable, + suffix, + ) + + +def select_best_version_tag(tags: Sequence[str]) -> str: + explicit_tags = [tag for tag in tags if is_explicit_version_tag(tag)] + if not explicit_tags: + raise ValueError("no explicit version tags available") + return max(explicit_tags, key=_version_sort_key) + + +def require_crane_binary() -> str: + crane_bin = shutil.which("crane") + if not crane_bin: + raise ValueError("crane is required to resolve floating image tags but was not found in PATH") + return crane_bin + + +def run_crane_command(crane_bin: str, args: Sequence[str]) -> str: + command = [crane_bin, *args] + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "unknown error" + raise ValueError(f"crane command failed ({' '.join(command)}): {detail}") + return result.stdout.strip() + + +def resolve_image_reference( + image: str, + *, + digest_cache: Optional[Dict[str, str]] = None, + tag_cache: Optional[Dict[str, List[str]]] = None, +) -> str: + repository, tag, digest = split_image_reference(image) + if digest: + return image.strip() + if not repository or not tag: + return image.strip() + if is_latest_image(image): + return image.strip() + if is_explicit_version_tag(tag): + return image.strip() + if not is_floating_tag(tag): + return image.strip() + + digest_cache = digest_cache if digest_cache is not None else {} + tag_cache = tag_cache if tag_cache is not None else {} + crane_bin = require_crane_binary() + + source_image = f"{repository}:{tag}" + source_digest = digest_cache.get(source_image) + if source_digest is None: + source_digest = run_crane_command(crane_bin, ["digest", source_image]) + digest_cache[source_image] = source_digest + + tags = tag_cache.get(repository) + if tags is None: + tags_output = run_crane_command(crane_bin, ["ls", repository]) + tags = [line.strip() for line in tags_output.splitlines() if line.strip()] + tag_cache[repository] = tags + + candidate_tags = [candidate for candidate in tags if is_explicit_version_tag(candidate)] + matched_tags: List[str] = [] + for candidate_tag in candidate_tags: + candidate_image = f"{repository}:{candidate_tag}" + candidate_digest = digest_cache.get(candidate_image) + if candidate_digest is None: + try: + candidate_digest = run_crane_command(crane_bin, ["digest", candidate_image]) + except ValueError: + continue + digest_cache[candidate_image] = candidate_digest + if candidate_digest == source_digest: + matched_tags.append(candidate_tag) + + if matched_tags: + best_tag = select_best_version_tag(matched_tags) + return f"{repository}:{best_tag}" + + return f"{repository}@{source_digest}" + + +def detect_db_type(image: str) -> Optional[str]: + normalized = image.strip().lower() + for db_type, patterns in DB_TYPE_PATTERNS.items(): + if any(pattern in normalized for pattern in patterns): + return db_type + return None + + +def _matches_gateway_hint(text: str, hints: Sequence[str]) -> bool: + normalized = text.strip().lower() + if not normalized: + return False + return any(hint in normalized for hint in hints) + + +def is_platform_edge_gateway_service(service_name: str, service: Mapping[str, Any], image: str) -> bool: + if not _matches_gateway_hint(service_name, EDGE_GATEWAY_SERVICE_HINTS) and not _matches_gateway_hint( + image, EDGE_GATEWAY_IMAGE_HINTS + ): + return False + + ports = parse_ports(service) + if any(port in EDGE_GATEWAY_PORT_HINTS for port in ports): + return True + + command_args = parse_command_args(service) + merged = " ".join(command_args).lower() + if _matches_gateway_hint(merged, EDGE_GATEWAY_COMMAND_HINTS): + return True + return False + + +def _resolve_compose_variable_expression(expr: str) -> str: + if ":-" in expr: + var_name, default = expr.split(":-", 1) + value = os.environ.get(var_name) + return value if value else default + if "-" in expr: + var_name, default = expr.split("-", 1) + value = os.environ.get(var_name) + return value if value is not None else default + if ":?" in expr: + var_name, message = expr.split(":?", 1) + value = os.environ.get(var_name) + if value: + return value + detail = message or f"{var_name} is required" + raise ValueError(detail) + if "?" in expr: + var_name, message = expr.split("?", 1) + value = os.environ.get(var_name) + if value is not None: + return value + detail = message or f"{var_name} is required" + raise ValueError(detail) + if ":+" in expr: + var_name, alternate = expr.split(":+", 1) + value = os.environ.get(var_name) + return alternate if value else "" + if "+" in expr: + var_name, alternate = expr.split("+", 1) + value = os.environ.get(var_name) + return alternate if value is not None else "" + var_name = expr.strip() + value = os.environ.get(var_name) + if value is None: + raise ValueError(f"environment variable {var_name} is required to resolve image") + return value + + +def resolve_compose_value(raw: str) -> str: + result = raw + + def _replace_braced(match: re.Match[str]) -> str: + return _resolve_compose_variable_expression(match.group(1)) + + result = COMPOSE_BRACED_VAR_RE.sub(_replace_braced, result) + + def _replace_simple(match: re.Match[str]) -> str: + var_name = match.group(1) + value = os.environ.get(var_name) + if value is None: + raise ValueError(f"environment variable {var_name} is required to resolve image") + return value + + result = COMPOSE_SIMPLE_VAR_RE.sub(_replace_simple, result) + return result + + +def normalize_image_reference(raw_image: str, service_name: str) -> str: + text = raw_image.strip() + if not text: + raise ValueError(f"service {service_name!r} must define image") + if "$" not in text: + return text + try: + resolved = resolve_compose_value(text).strip() + except ValueError as exc: + raise ValueError(f"service {service_name!r} image interpolation cannot be resolved: {exc}") from exc + if not resolved: + raise ValueError(f"service {service_name!r} image interpolation resolved to an empty value") + if "$" in resolved or "${" in resolved: + raise ValueError(f"service {service_name!r} image interpolation resolved incompletely: {resolved}") + return resolved + + +def parse_compose(compose_path: Path) -> Mapping[str, Any]: + data = yaml.safe_load(compose_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("compose file must be a YAML object") + services = data.get("services") + if not isinstance(services, dict) or not services: + raise ValueError("compose file must contain a non-empty services map") + return data + + +def infer_app_name(compose_data: Mapping[str, Any], compose_path: Path) -> str: + compose_name = compose_data.get("name") + if isinstance(compose_name, str) and compose_name.strip(): + return normalize_k8s_name(compose_name) + return normalize_k8s_name(compose_path.stem) + + +def normalize_category(raw: str) -> str: + value = INVALID_NAME_RE.sub("-", raw.strip().lower()).strip("-") + if not value: + return "" + return CATEGORY_ALIASES.get(value, value) + + +def normalize_categories(values: Sequence[str]) -> Tuple[str, ...]: + categories: List[str] = [] + for item in values: + if not isinstance(item, str): + continue + normalized = normalize_category(item) + if normalized not in ALLOWED_TEMPLATE_CATEGORIES: + continue + if normalized in categories: + continue + categories.append(normalized) + if not categories: + return ("tool",) + return tuple(categories) + + +def infer_metadata(opts: argparse.Namespace, compose_data: Mapping[str, Any], compose_path: Path) -> MetadataOptions: + app_name = normalize_k8s_name(opts.app_name) if opts.app_name else infer_app_name(compose_data, compose_path) + title = opts.title or app_name.replace("-", " ").title() + description = opts.description or f"Generated Sealos template for {title} from Docker Compose." + url = opts.url or f"https://example.com/{app_name}" + git_repo = opts.git_repo or f"https://github.com/example/{app_name}" + categories = normalize_categories(opts.category or ("tool",)) + return MetadataOptions( + app_name=app_name, + title=title, + description=description, + url=url, + git_repo=git_repo, + author=opts.author, + categories=tuple(categories), + repo_raw_base=opts.repo_raw_base.rstrip("/"), + ) + + +def build_zh_description(title: str, description: str) -> str: + raw = re.sub(r"\s+", " ", description.strip()) + if raw and ZH_CHAR_RE.search(raw): + return raw + rewritten = rewrite_english_description_to_zh(raw) + if rewritten: + return rewritten + if raw: + return f"{title} 的 Sealos 模板,提供 {title} 应用的部署能力。" + return f"{title} 的 Sealos 模板。" + + +def rewrite_english_description_to_zh(description: str) -> str: + normalized = description.strip().strip(".") + if not normalized: + return "" + lowered = normalized.lower() + + for pattern, rewritten in EN_DESCRIPTION_REWRITE_PATTERNS: + if pattern.search(lowered): + return f"{rewritten}。" + + translated = lowered + for source, target in EN_DESCRIPTION_TERM_REPLACEMENTS: + translated = re.sub(rf"\b{re.escape(source)}\b", target, translated) + translated = re.sub(r"\s+", " ", translated).strip(" ,;") + translated = translated.replace(",", ",").replace(";", ";").replace(":", ":") + translated = re.sub(r"\s*,\s*", ",", translated) + translated = re.sub(r"\s*;\s*", ";", translated) + translated = re.sub(r"\s*:\s*", ":", translated) + translated = re.sub(r"\s+", " ", translated).strip() + if not translated or not ZH_CHAR_RE.search(translated): + return "" + if translated.endswith(("。", "!", "?")): + return translated + return f"{translated}。" + + +def parse_env(service: Mapping[str, Any]) -> List[Tuple[str, str]]: + env = service.get("environment") + result: List[Tuple[str, str]] = [] + if isinstance(env, dict): + for key, value in env.items(): + result.append((str(key), "" if value is None else str(value))) + return result + if isinstance(env, list): + for item in env: + if isinstance(item, str): + if "=" in item: + key, value = item.split("=", 1) + result.append((key, value)) + else: + result.append((item, "")) + elif isinstance(item, dict): + for key, value in item.items(): + result.append((str(key), "" if value is None else str(value))) + return result + + +def parse_container_port(item: Any) -> Optional[int]: + if isinstance(item, int): + return item + if isinstance(item, str): + text = item.strip() + if not text: + return None + if "/" in text: + text = text.split("/", 1)[0] + if ":" in text: + text = text.rsplit(":", 1)[-1] + if "-" in text: + text = text.split("-", 1)[0] + return int(text) if text.isdigit() else None + if isinstance(item, dict): + target = item.get("target") + if isinstance(target, int): + return target + if isinstance(target, str) and target.isdigit(): + return int(target) + return None + + +def parse_ports(service: Mapping[str, Any]) -> List[int]: + ports = service.get("ports") + if not isinstance(ports, list): + return [] + values: List[int] = [] + seen = set() + for item in ports: + port = parse_container_port(item) + if port is None or port in seen: + continue + seen.add(port) + values.append(port) + return values + + +def normalize_ports_for_gateway_tls_termination(ports: Sequence[int]) -> List[int]: + normalized = list(ports) + # Sealos Ingress terminates TLS. If an app exposes both HTTP and HTTPS ports, + # keep only HTTP-facing ports to avoid redundant in-container TLS. + if TLS_TERMINATION_PORT in normalized and any(port != TLS_TERMINATION_PORT for port in normalized): + normalized = [port for port in normalized if port != TLS_TERMINATION_PORT] + return normalized + + +def parse_mount_target_from_string(raw: str) -> Optional[str]: + text = raw.strip() + if not text: + return None + parts = text.split(":") + if len(parts) == 1: + return parts[0] if parts[0].startswith("/") else None + if len(parts) >= 3 and parts[-1] in MODE_SUFFIXES: + target = parts[-2] + else: + target = parts[-1] + return target if target.startswith("/") else None + + +def is_persistent_mount_target(target: str) -> bool: + if not target.startswith("/"): + return False + # Runtime sockets (for example docker.sock) should not be converted to PVC. + return not target.lower().endswith(".sock") + + +def is_tls_certificate_mount_target(target: str) -> bool: + normalized = target.strip().rstrip("/").lower() + if not normalized: + return False + if normalized in TLS_CERT_MOUNT_EXACT_PATHS: + return True + parts = [part for part in normalized.split("/") if part] + if not parts: + return False + return parts[-1] in TLS_CERT_DIR_NAMES + + +def parse_mount_paths(service: Mapping[str, Any]) -> List[str]: + volumes = service.get("volumes") + if not isinstance(volumes, list): + return [] + paths: List[str] = [] + seen = set() + for item in volumes: + target: Optional[str] = None + if isinstance(item, str): + target = parse_mount_target_from_string(item) + elif isinstance(item, dict): + raw_target = item.get("target") + if isinstance(raw_target, str) and raw_target.startswith("/"): + target = raw_target + if ( + target + and is_persistent_mount_target(target) + and not is_tls_certificate_mount_target(target) + and target not in seen + ): + seen.add(target) + paths.append(target) + return paths + + +def parse_command_args(service: Mapping[str, Any]) -> List[str]: + command = service.get("command") + if isinstance(command, str): + text = command.strip() + if not text: + return [] + try: + return shlex.split(text) + except ValueError: + return [text] + if isinstance(command, list): + return [str(item) for item in command if item is not None and str(item).strip()] + return [] + + +def parse_compose_duration_seconds(raw: Any) -> Optional[int]: + if isinstance(raw, (int, float)): + return max(1, int(math.ceil(float(raw)))) + if not isinstance(raw, str): + return None + text = raw.strip().lower() + if not text: + return None + if text.isdigit(): + return max(1, int(text)) + + unit_to_seconds = { + "ns": 1e-9, + "us": 1e-6, + "ms": 1e-3, + "s": 1.0, + "m": 60.0, + "h": 3600.0, + } + total_seconds = 0.0 + cursor = 0 + for match in COMPOSE_DURATION_PART_RE.finditer(text): + if match.start() != cursor: + return None + value = int(match.group(1)) + unit = match.group(2) + total_seconds += value * unit_to_seconds[unit] + cursor = match.end() + if cursor != len(text): + return None + return max(1, int(math.ceil(total_seconds))) + + +def build_probe_timing_fields(healthcheck: Mapping[str, Any]) -> Dict[str, int]: + interval = parse_compose_duration_seconds(healthcheck.get("interval")) + timeout = parse_compose_duration_seconds(healthcheck.get("timeout")) + start_period = parse_compose_duration_seconds(healthcheck.get("start_period")) + + retries_raw = healthcheck.get("retries") + retries: Optional[int] = None + if isinstance(retries_raw, int): + retries = retries_raw + elif isinstance(retries_raw, str) and retries_raw.strip().isdigit(): + retries = int(retries_raw.strip()) + + return { + "initialDelaySeconds": max(1, start_period or 10), + "periodSeconds": max(1, interval or 10), + "timeoutSeconds": max(1, timeout or 5), + "failureThreshold": max(1, retries or 3), + } + + +def parse_compose_healthcheck_command(healthcheck: Mapping[str, Any]) -> Optional[List[str]]: + test = healthcheck.get("test") + if isinstance(test, str): + value = test.strip() + if not value: + return None + if value.upper() == "NONE": + return [] + return ["sh", "-c", value] + + if isinstance(test, list): + tokens = [str(item).strip() for item in test if str(item).strip()] + if not tokens: + return None + mode = tokens[0].upper() + if mode == "NONE": + return [] + if mode == "CMD": + return tokens[1:] + if mode == "CMD-SHELL": + shell = " ".join(tokens[1:]).strip() + if not shell: + return None + return ["sh", "-c", shell] + return tokens + return None + + +def extract_http_get_action_from_command(command: Sequence[str], ports: Sequence[int]) -> Optional[Dict[str, Any]]: + merged = " ".join(command) + url_match = URL_IN_COMMAND_RE.search(merged) + if url_match is None: + return None + parsed = urlparse(url_match.group(0)) + scheme = parsed.scheme.upper() if parsed.scheme else "HTTP" + if scheme not in {"HTTP", "HTTPS"}: + scheme = "HTTP" + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + port = parsed.port or (443 if scheme == "HTTPS" else 80) + if parsed.hostname in {"localhost", "127.0.0.1"} and ports: + if port not in ports: + port = ports[0] + return { + "httpGet": { + "path": path, + "port": port, + "scheme": scheme, + } + } + + +def build_probe_pair_from_compose_healthcheck(service: Mapping[str, Any], ports: Sequence[int]) -> Dict[str, Any]: + healthcheck = service.get("healthcheck") + if not isinstance(healthcheck, dict): + return {} + + command = parse_compose_healthcheck_command(healthcheck) + if command is None: + return {} + if not command: + return {} + + action = extract_http_get_action_from_command(command, ports) + if action is None: + action = { + "exec": { + "command": list(command), + } + } + timing = build_probe_timing_fields(healthcheck) + liveness = dict(action) + liveness.update(timing) + readiness = dict(action) + readiness.update(timing) + result = { + "livenessProbe": liveness, + "readinessProbe": readiness, + } + start_period = parse_compose_duration_seconds(healthcheck.get("start_period")) + if start_period and start_period > 0: + period = int(timing.get("periodSeconds", 10)) + startup = dict(action) + startup.update( + { + "periodSeconds": max(1, period), + "timeoutSeconds": int(timing.get("timeoutSeconds", 5)), + "failureThreshold": max(1, int(math.ceil(start_period / max(1, period)))), + } + ) + result["startupProbe"] = startup + return result + + +def is_worker_command(command_args: Sequence[str]) -> bool: + if not command_args: + return False + first = str(command_args[0]).strip().lower() + return first == "worker" + + +def pick_probe_port(ports: Sequence[int], preferred_port: int) -> int: + if preferred_port in ports: + return preferred_port + if ports: + return int(ports[0]) + return preferred_port + + +def build_probe_pair_from_official_profile( + image: str, + ports: Sequence[int], + command_args: Sequence[str], +) -> Dict[str, Any]: + image_lower = image.strip().lower() + + for marker, profile in OFFICIAL_HEALTH_WORKER_PROFILES.items(): + if marker in image_lower and is_worker_command(command_args): + action = { + "exec": { + "command": list(profile["command"]), + } + } + startup_action = { + "exec": { + "command": list(profile.get("startup_command", profile["command"])), + } + } + timing = { + "initialDelaySeconds": int(profile["initialDelaySeconds"]), + "periodSeconds": int(profile["periodSeconds"]), + "timeoutSeconds": int(profile["timeoutSeconds"]), + "failureThreshold": int(profile["failureThreshold"]), + } + liveness = dict(action) + liveness.update(timing) + readiness = dict(action) + readiness.update(timing) + startup = dict(startup_action) + startup.update( + { + "periodSeconds": int(profile["startupPeriodSeconds"]), + "timeoutSeconds": int(profile["startupTimeoutSeconds"]), + "failureThreshold": int(profile["startupFailureThreshold"]), + } + ) + return { + "livenessProbe": liveness, + "readinessProbe": readiness, + "startupProbe": startup, + } + + for marker, profile in OFFICIAL_HEALTH_HTTP_PROFILES.items(): + if marker not in image_lower: + continue + port = pick_probe_port(ports, int(profile["preferred_port"])) + timing = { + "initialDelaySeconds": int(profile["initialDelaySeconds"]), + "periodSeconds": int(profile["periodSeconds"]), + "timeoutSeconds": int(profile["timeoutSeconds"]), + "failureThreshold": int(profile["failureThreshold"]), + } + liveness = { + "httpGet": { + "path": str(profile["liveness_path"]), + "port": port, + "scheme": str(profile["scheme"]), + } + } + liveness.update(timing) + readiness = { + "httpGet": { + "path": str(profile["readiness_path"]), + "port": port, + "scheme": str(profile["scheme"]), + } + } + readiness.update(timing) + startup = { + "httpGet": { + "path": str(profile["startup_path"]), + "port": port, + "scheme": str(profile["scheme"]), + }, + "periodSeconds": int(profile["startupPeriodSeconds"]), + "timeoutSeconds": int(profile["startupTimeoutSeconds"]), + "failureThreshold": int(profile["startupFailureThreshold"]), + } + return { + "livenessProbe": liveness, + "readinessProbe": readiness, + "startupProbe": startup, + } + + return {} + + +def build_probe_pair( + service: Mapping[str, Any], + image: str, + ports: Sequence[int], + command_args: Sequence[str], +) -> Dict[str, Any]: + from_compose = build_probe_pair_from_compose_healthcheck(service, ports) + if from_compose: + return from_compose + return build_probe_pair_from_official_profile(image, ports, command_args) + + +def _extract_shape_from_kompose_doc(doc: Mapping[str, Any]) -> Optional[Tuple[str, ServiceShape]]: + kind = doc.get("kind") + if kind not in {"Deployment", "StatefulSet", "DaemonSet"}: + return None + metadata = doc.get("metadata") + name = metadata.get("name") if isinstance(metadata, dict) else None + if not isinstance(name, str) or not name.strip(): + return None + + spec = doc.get("spec") + template = spec.get("template") if isinstance(spec, dict) else None + template_spec = template.get("spec") if isinstance(template, dict) else None + containers = template_spec.get("containers") if isinstance(template_spec, dict) else None + if not isinstance(containers, list) or not containers: + return None + first = containers[0] if isinstance(containers[0], dict) else None + if not isinstance(first, dict): + return None + + ports_raw = first.get("ports") + ports: List[int] = [] + seen_ports = set() + if isinstance(ports_raw, list): + for item in ports_raw: + if not isinstance(item, dict): + continue + container_port = item.get("containerPort") + if isinstance(container_port, int) and container_port not in seen_ports: + seen_ports.add(container_port) + ports.append(container_port) + + mounts_raw = first.get("volumeMounts") + mounts: List[str] = [] + seen_mounts = set() + if isinstance(mounts_raw, list): + for item in mounts_raw: + if not isinstance(item, dict): + continue + mount_path = item.get("mountPath") + if isinstance(mount_path, str) and mount_path.startswith("/") and mount_path not in seen_mounts: + seen_mounts.add(mount_path) + mounts.append(mount_path) + + return normalize_k8s_name(name), ServiceShape(ports=tuple(ports), mount_paths=tuple(mounts)) + + +def load_service_shapes_with_kompose(compose_path: Path, required: bool) -> Optional[Dict[str, ServiceShape]]: + kompose_bin = shutil.which("kompose") + if not kompose_bin: + if required: + raise ValueError("kompose is required but not found in PATH") + return None + + with tempfile.TemporaryDirectory() as temp_dir: + workdir = Path(temp_dir) + cmd = [kompose_bin, "convert", "-f", str(compose_path)] + result = subprocess.run(cmd, cwd=workdir, capture_output=True, text=True) + if result.returncode != 0: + if required: + stderr = result.stderr.strip() or result.stdout.strip() or "unknown error" + raise ValueError(f"kompose convert failed: {stderr}") + return None + + shapes: Dict[str, ServiceShape] = {} + for path in sorted([*workdir.glob("*.yaml"), *workdir.glob("*.yml")]): + text = path.read_text(encoding="utf-8") + for doc in yaml.safe_load_all(text): + if not isinstance(doc, dict): + continue + extracted = _extract_shape_from_kompose_doc(doc) + if extracted is None: + continue + key, shape = extracted + shapes.setdefault(key, shape) + + if required and not shapes: + raise ValueError("kompose produced no workload manifests") + return shapes + + +def resolve_kompose_shapes(compose_path: Path, mode: str) -> Optional[Dict[str, ServiceShape]]: + if mode == "never": + return None + if mode == "always": + return load_service_shapes_with_kompose(compose_path, required=True) + if mode == "auto": + return load_service_shapes_with_kompose(compose_path, required=False) + raise ValueError(f"unsupported kompose mode: {mode}") + + +def build_template_resource(meta: MetadataOptions) -> Dict[str, Any]: + readme_base = f"{TEMPLATE_README_BASE}/{meta.app_name}" + return { + "apiVersion": "app.sealos.io/v1", + "kind": "Template", + "metadata": { + "name": meta.app_name, + }, + "spec": { + "title": meta.title, + "url": meta.url, + "gitRepo": meta.git_repo, + "author": meta.author, + "description": meta.description, + "readme": f"{readme_base}/README.md", + "icon": f"{meta.repo_raw_base}/template/{meta.app_name}/logo.png", + "templateType": "inline", + "locale": "en", + "i18n": { + "zh": { + "description": build_zh_description(meta.title, meta.description), + "readme": f"{readme_base}/README_zh.md", + } + }, + "categories": list(meta.categories), + "defaults": { + "app_host": { + "type": "string", + "value": f"{meta.app_name}-${{{{ random(8) }}}}", + }, + "app_name": { + "type": "string", + "value": f"{meta.app_name}-${{{{ random(8) }}}}", + }, + }, + }, + } + + +def build_postgres_resources() -> List[Dict[str, Any]]: + name = "${{ defaults.app_name }}-pg" + labels = { + "sealos-db-provider-cr": name, + "app.kubernetes.io/instance": name, + "app.kubernetes.io/managed-by": "kbcli", + } + return [ + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "name": name, + "labels": labels, + }, + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": { + "name": name, + "labels": labels, + }, + "rules": [ + { + "apiGroups": ["*"], + "resources": ["*"], + "verbs": ["*"], + } + ], + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": { + "name": name, + "labels": labels, + }, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "Role", + "name": name, + }, + "subjects": [ + { + "kind": "ServiceAccount", + "name": name, + } + ], + }, + { + "apiVersion": "apps.kubeblocks.io/v1alpha1", + "kind": "Cluster", + "metadata": { + "name": name, + "labels": { + "kb.io/database": "postgresql-16.4.0", + "clusterdefinition.kubeblocks.io/name": "postgresql", + "clusterversion.kubeblocks.io/name": "postgresql-16.4.0", + }, + }, + "spec": { + "affinity": { + "podAntiAffinity": "Preferred", + "tenancy": "SharedNode", + }, + "clusterDefinitionRef": "postgresql", + "clusterVersionRef": "postgresql-16.4.0", + "terminationPolicy": "Delete", + "componentSpecs": [ + { + "name": "postgresql", + "componentDefRef": "postgresql", + "disableExporter": True, + "enabledLogs": ["running"], + "replicas": 1, + "serviceAccountName": name, + "switchPolicy": {"type": "Noop"}, + "resources": db_component_resources(), + "volumeClaimTemplates": [ + { + "name": "data", + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + "storageClassName": "openebs-backup", + }, + } + ], + } + ], + }, + }, + ] + + +def build_mysql_resources() -> List[Dict[str, Any]]: + name = "${{ defaults.app_name }}-mysql" + labels = { + "sealos-db-provider-cr": name, + "app.kubernetes.io/instance": name, + "app.kubernetes.io/managed-by": "kbcli", + } + return [ + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "name": name, + "labels": labels, + }, + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": { + "name": name, + "labels": labels, + }, + "rules": [ + { + "apiGroups": ["*"], + "resources": ["*"], + "verbs": ["*"], + } + ], + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": { + "name": name, + "labels": labels, + }, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "Role", + "name": name, + }, + "subjects": [ + { + "kind": "ServiceAccount", + "name": name, + } + ], + }, + { + "apiVersion": "apps.kubeblocks.io/v1alpha1", + "kind": "Cluster", + "metadata": { + "name": name, + "labels": { + "kb.io/database": "ac-mysql-8.0.30-1", + "clusterdefinition.kubeblocks.io/name": "apecloud-mysql", + "clusterversion.kubeblocks.io/name": "ac-mysql-8.0.30-1", + }, + }, + "spec": { + "affinity": { + "nodeLabels": {}, + "podAntiAffinity": "Preferred", + "tenancy": "SharedNode", + "topologyKeys": ["kubernetes.io/hostname"], + }, + "clusterDefinitionRef": "apecloud-mysql", + "clusterVersionRef": "ac-mysql-8.0.30-1", + "componentSpecs": [ + { + "name": "mysql", + "componentDefRef": "mysql", + "monitor": True, + "noCreatePDB": False, + "replicas": 1, + "serviceAccountName": name, + "switchPolicy": {"type": "Noop"}, + "resources": db_component_resources(), + "volumeClaimTemplates": [ + { + "name": "data", + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + "storageClassName": "openebs-backup", + }, + } + ], + } + ], + "terminationPolicy": "Delete", + "tolerations": [], + }, + }, + ] + + +def build_mongodb_resources() -> List[Dict[str, Any]]: + name = "${{ defaults.app_name }}-mongo" + labels = { + "sealos-db-provider-cr": name, + "app.kubernetes.io/instance": name, + "app.kubernetes.io/managed-by": "kbcli", + } + return [ + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "name": name, + "labels": labels, + }, + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": { + "name": name, + "labels": labels, + }, + "rules": [ + { + "apiGroups": ["*"], + "resources": ["*"], + "verbs": ["*"], + } + ], + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": { + "name": name, + "labels": labels, + }, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "Role", + "name": name, + }, + "subjects": [ + { + "kind": "ServiceAccount", + "name": name, + } + ], + }, + { + "apiVersion": "apps.kubeblocks.io/v1alpha1", + "kind": "Cluster", + "metadata": { + "name": name, + "labels": { + "kb.io/database": "mongodb-8.0.4", + "app.kubernetes.io/instance": name, + }, + }, + "spec": { + "affinity": { + "podAntiAffinity": "Preferred", + "tenancy": "SharedNode", + "topologyKeys": ["kubernetes.io/hostname"], + }, + "componentSpecs": [ + { + "name": "mongodb", + "componentDef": "mongodb", + "serviceVersion": "8.0.4", + "replicas": 1, + "serviceAccountName": name, + "resources": db_component_resources(), + "volumeClaimTemplates": [ + { + "name": "data", + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + "storageClassName": "openebs-backup", + }, + } + ], + } + ], + "terminationPolicy": "Delete", + }, + }, + ] + + +def build_redis_resources() -> List[Dict[str, Any]]: + name = "${{ defaults.app_name }}-redis" + labels = { + "sealos-db-provider-cr": name, + "app.kubernetes.io/instance": name, + "app.kubernetes.io/managed-by": "kbcli", + } + return [ + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "name": name, + "labels": labels, + }, + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": { + "name": name, + "labels": labels, + }, + "rules": [ + { + "apiGroups": ["*"], + "resources": ["*"], + "verbs": ["*"], + } + ], + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": { + "name": name, + "labels": labels, + }, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "Role", + "name": name, + }, + "subjects": [ + { + "kind": "ServiceAccount", + "name": name, + } + ], + }, + { + "apiVersion": "apps.kubeblocks.io/v1alpha1", + "kind": "Cluster", + "metadata": { + "name": name, + "labels": { + "kb.io/database": "redis-7.2.7", + "app.kubernetes.io/instance": name, + "app.kubernetes.io/version": "7.2.7", + "clusterversion.kubeblocks.io/name": "redis-7.2.7", + "clusterdefinition.kubeblocks.io/name": "redis", + }, + }, + "spec": { + "affinity": { + "podAntiAffinity": "Preferred", + "tenancy": "SharedNode", + "topologyKeys": ["kubernetes.io/hostname"], + }, + "clusterDefinitionRef": "redis", + "componentSpecs": [ + { + "name": "redis", + "componentDef": "redis-7", + "serviceVersion": "7.2.7", + "replicas": 1, + "serviceAccountName": name, + "enabledLogs": ["running"], + "env": [{"name": "CUSTOM_SENTINEL_MASTER_NAME"}], + "switchPolicy": {"type": "Noop"}, + "resources": db_component_resources(), + "volumeClaimTemplates": [ + { + "name": "data", + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + "storageClassName": "openebs-backup", + }, + } + ], + }, + { + "name": "redis-sentinel", + "componentDef": "redis-sentinel-7", + "serviceVersion": "7.2.7", + "replicas": 1, + "serviceAccountName": name, + "resources": db_component_resources(), + "volumeClaimTemplates": [ + { + "name": "data", + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + }, + } + ], + }, + ], + "terminationPolicy": "Delete", + "topology": "replication", + }, + }, + ] + + +def build_kafka_resources() -> List[Dict[str, Any]]: + name = "${{ defaults.app_name }}-broker" + labels = { + "sealos-db-provider-cr": name, + "app.kubernetes.io/instance": name, + "app.kubernetes.io/managed-by": "kbcli", + } + return [ + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "name": name, + "labels": labels, + }, + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": { + "name": name, + "labels": labels, + }, + "rules": [ + { + "apiGroups": ["*"], + "resources": ["*"], + "verbs": ["*"], + } + ], + }, + { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": { + "name": name, + "labels": labels, + }, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "Role", + "name": name, + }, + "subjects": [ + { + "kind": "ServiceAccount", + "name": name, + } + ], + }, + { + "apiVersion": "apps.kubeblocks.io/v1alpha1", + "kind": "Cluster", + "metadata": { + "name": name, + "finalizers": ["cluster.kubeblocks.io/finalizer"], + "labels": { + "kb.io/database": "kafka-3.3.2", + "clusterdefinition.kubeblocks.io/name": "kafka", + "clusterversion.kubeblocks.io/name": "kafka-3.3.2", + }, + "annotations": { + "kubeblocks.io/extra-env": ( + '{"KB_KAFKA_ENABLE_SASL":"false","KB_KAFKA_BROKER_HEAP":"-XshowSettings:vm ' + '-XX:MaxRAMPercentage=100 -Ddepth=64","KB_KAFKA_CONTROLLER_HEAP":"-XshowSettings:vm ' + '-XX:MaxRAMPercentage=100 -Ddepth=64","KB_KAFKA_PUBLIC_ACCESS":"false"}' + ) + }, + }, + "spec": { + "terminationPolicy": "Delete", + "componentSpecs": [ + { + "name": "broker", + "componentDef": "kafka-broker", + "tls": False, + "replicas": 1, + "affinity": { + "podAntiAffinity": "Preferred", + "topologyKeys": ["kubernetes.io/hostname"], + "tenancy": "SharedNode", + }, + "tolerations": [ + { + "key": "kb-data", + "operator": "Equal", + "value": "true", + "effect": "NoSchedule", + } + ], + "resources": { + "limits": {"cpu": "500m", "memory": "512Mi"}, + "requests": {"cpu": "50m", "memory": "51Mi"}, + }, + "volumeClaimTemplates": [ + { + "name": "data", + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + }, + }, + { + "name": "metadata", + "spec": { + "storageClassName": None, + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + }, + }, + ], + }, + { + "name": "controller", + "componentDefRef": "controller", + "componentDef": "kafka-controller", + "tls": False, + "replicas": 1, + "resources": db_component_resources(), + "volumeClaimTemplates": [ + { + "name": "metadata", + "spec": { + "storageClassName": None, + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + }, + } + ], + }, + { + "name": "metrics-exp", + "componentDef": "kafka-exporter", + "replicas": 1, + "resources": db_component_resources(), + }, + ], + }, + }, + ] + + +def build_database_resources(db_type: str) -> List[Dict[str, Any]]: + if db_type == "postgres": + return build_postgres_resources() + if db_type == "mysql": + return build_mysql_resources() + if db_type == "mongodb": + return build_mongodb_resources() + if db_type == "redis": + return build_redis_resources() + if db_type == "kafka": + return build_kafka_resources() + return [] + + +def build_object_storage_bucket() -> Dict[str, Any]: + return { + "apiVersion": "objectstorage.sealos.io/v1", + "kind": "ObjectStorageBucket", + "metadata": {"name": "${{ defaults.app_name }}"}, + "spec": {"policy": "private"}, + } + + +def map_compose_env_value(value: str, db_hosts: Mapping[str, str]) -> str: + if not isinstance(value, str): + return str(value) + if COMPOSE_REFERENCE_RE.search(value): + return value + if value in db_hosts: + return db_hosts[value] + mapped = value + for service_name, fqdn in db_hosts.items(): + mapped = mapped.replace(f"@{service_name}:", f"@{fqdn}:") + mapped = mapped.replace(f"//{service_name}:", f"//{fqdn}:") + return mapped + + +def detect_db_connection_key(env_name: str) -> Optional[str]: + upper = re.sub(r"[^A-Z0-9]+", "_", env_name.upper()) + + if re.search(r"(?:^|_)(?:PASSWORD|PASS|PWD)(?:$|_)", upper): + return "password" + if re.search(r"(?:^|_)(?:USERNAME|USER)(?:$|_)", upper): + return "username" + if re.search(r"(?:^|_)(?:ENDPOINT|URI|URL|DSN)(?:$|_)", upper): + return "endpoint" + if re.search(r"(?:^|_)(?:HOST|SERVER)(?:$|_)", upper): + return "host" + if re.search(r"(?:^|_)(?:PORT)(?:$|_)", upper): + return "port" + return None + + +def normalize_env_token(value: str) -> str: + return re.sub(r"[^A-Z0-9]+", "_", value.upper()).strip("_") + + +def normalize_endpoint_helper_token(value: str) -> str: + token = normalize_env_token(value) + if not token: + return "" + filtered = [part for part in token.split("_") if part and part not in {"URL", "URI", "DSN", "ENDPOINT"}] + return "_".join(filtered) + + +def build_secret_ref_env_entry(env_name: str, secret_name: str, secret_key: str) -> Dict[str, Any]: + return { + "name": env_name, + "valueFrom": { + "secretKeyRef": { + "name": secret_name, + "key": secret_key, + } + }, + } + + +def infer_db_type_from_value(value: str, db_services: Mapping[str, str]) -> Optional[str]: + text = value.strip().lower() + matched: List[str] = [] + for service_name, db_type in db_services.items(): + service = service_name.lower() + if text == service: + matched.append(db_type) + continue + if f"//{service}" in text or f"@{service}" in text or f"{service}:" in text: + matched.append(db_type) + continue + unique = sorted(set(matched)) + if len(unique) == 1: + return unique[0] + return None + + +def infer_db_type_from_env_name(env_name: str, available_db_types: Sequence[str]) -> Optional[str]: + upper = env_name.upper() + candidates: List[str] = [] + for db_type in sorted(set(available_db_types)): + hints = DB_ENV_HINTS_BY_TYPE.get(db_type, ()) + if any(hint in upper for hint in hints): + candidates.append(db_type) + + unique = sorted(set(candidates)) + if len(unique) == 1: + return unique[0] + + deduped = sorted(set(available_db_types)) + if ("DB" in upper or "DATABASE" in upper) and len(deduped) == 1: + return deduped[0] + return None + + +def infer_db_secret_ref(env_name: str, value: str, db_services: Mapping[str, str]) -> Optional[Dict[str, str]]: + connection_key = detect_db_connection_key(env_name) + if connection_key is None: + return None + + available_db_types = list(db_services.values()) + if not available_db_types: + return None + + from_value = infer_db_type_from_value(value, db_services) + from_name = infer_db_type_from_env_name(env_name, available_db_types) + db_type = from_value or from_name + if db_type is None: + return None + + # Some KubeBlocks account secrets only expose credentials. Host/port use + # stable Sealos Service FQDN values for those databases instead. + if db_type == "redis" and connection_key in {"host", "port"}: + return None + + secret_name = DB_SECRET_NAME_BY_TYPE.get(db_type) + if not isinstance(secret_name, str): + return None + + return {"name": secret_name, "key": connection_key, "db_type": db_type} + + +def build_db_url_composed_env_entries( + env_name: str, + raw_value: str, + secret_name: str, + db_type: str, + db_services: Mapping[str, str], +) -> Optional[List[Dict[str, Any]]]: + text = raw_value.strip() + if not text or COMPOSE_REFERENCE_RE.search(text): + return None + + parsed = urlparse(text) + host = (parsed.hostname or "").strip().lower() + if not parsed.scheme or not host or host not in db_services: + return None + + env_token = normalize_endpoint_helper_token(env_name) or "DB_CONNECTION" + db_token = normalize_env_token(db_type) or "DB" + + host_var = f"SEALOS_{env_token}_{db_token}_HOST" + port_var = f"SEALOS_{env_token}_{db_token}_PORT" + user_var = f"SEALOS_{env_token}_{db_token}_USERNAME" + password_var = f"SEALOS_{env_token}_{db_token}_PASSWORD" + + helper_entries: List[Dict[str, Any]] + if db_type == "redis": + helper_entries = [ + {"name": host_var, "value": DB_FQDN_BY_TYPE["redis"]}, + {"name": port_var, "value": "6379"}, + ] + elif db_type == "mongodb": + helper_entries = [ + {"name": host_var, "value": DB_FQDN_BY_TYPE["mongodb"]}, + {"name": port_var, "value": "27017"}, + ] + else: + helper_entries = [ + build_secret_ref_env_entry(host_var, secret_name, "host"), + build_secret_ref_env_entry(port_var, secret_name, "port"), + ] + + auth_prefix = "" + has_auth = "@" in parsed.netloc + has_username = parsed.username not in (None, "") + has_password = parsed.password is not None + + if has_username: + helper_entries.append(build_secret_ref_env_entry(user_var, secret_name, "username")) + if has_password: + helper_entries.append(build_secret_ref_env_entry(password_var, secret_name, "password")) + + if has_auth: + if has_username and has_password: + auth_prefix = f"$({user_var}):$({password_var})@" + elif has_username: + auth_prefix = f"$({user_var})@" + elif has_password: + auth_prefix = f":$({password_var})@" + + host_port = f"$({host_var})" + if parsed.port is not None or db_type in {"redis", "mongodb"}: + host_port = f"{host_port}:$({port_var})" + + suffix = parsed.path or "" + if parsed.query: + suffix = f"{suffix}?{parsed.query}" + if parsed.fragment: + suffix = f"{suffix}#{parsed.fragment}" + + composed_url = f"{parsed.scheme}://{auth_prefix}{host_port}{suffix}" + helper_entries.append({"name": env_name, "value": composed_url}) + return helper_entries + + +def build_env_entries( + service: Mapping[str, Any], + db_hosts: Mapping[str, str], + db_services: Mapping[str, str], +) -> List[Dict[str, Any]]: + entries: List[Dict[str, Any]] = [] + for key, value in parse_env(service): + secret_ref = infer_db_secret_ref(key, value, db_services) + if secret_ref is not None: + if secret_ref["key"] == "endpoint": + composed_entries = build_db_url_composed_env_entries( + env_name=key, + raw_value=value, + secret_name=secret_ref["name"], + db_type=secret_ref["db_type"], + db_services=db_services, + ) + if composed_entries is not None: + entries.extend(composed_entries) + continue + + entries.append(build_secret_ref_env_entry(key, secret_ref["name"], secret_ref["key"])) + continue + entries.append( + { + "name": key, + "value": map_compose_env_value(value, db_hosts), + } + ) + return entries + + +def build_workload( + *, + workload_name: str, + pull_secret_name: str, + image: str, + ports: Sequence[int], + env_entries: Sequence[Dict[str, Any]], + command_args: Sequence[str], + mount_paths: Sequence[str], + probes: Mapping[str, Any], +) -> Dict[str, Any]: + is_stateful = bool(mount_paths) + kind = "StatefulSet" if is_stateful else "Deployment" + template_spec: Dict[str, Any] = { + "automountServiceAccountToken": False, + "imagePullSecrets": [{"name": pull_secret_name}], + "containers": [ + { + "name": workload_name, + "image": image, + "imagePullPolicy": "IfNotPresent", + "resources": { + "limits": dict(DEFAULT_RESOURCE_LIMITS), + "requests": dict(DEFAULT_RESOURCE_REQUESTS), + }, + } + ], + } + container = template_spec["containers"][0] + if ports: + container["ports"] = [{"containerPort": p} for p in ports] + if env_entries: + container["env"] = list(env_entries) + if command_args: + container["args"] = list(command_args) + if probes: + for key in ("livenessProbe", "readinessProbe", "startupProbe"): + value = probes.get(key) + if isinstance(value, dict): + container[key] = value + if mount_paths: + container["volumeMounts"] = [ + { + "name": path_to_vn_name(path), + "mountPath": path, + } + for path in mount_paths + ] + + spec: Dict[str, Any] = { + "replicas": 1, + "revisionHistoryLimit": 1, + "selector": {"matchLabels": {"app": workload_name}}, + "template": { + "metadata": {"labels": {"app": workload_name}}, + "spec": template_spec, + }, + } + if is_stateful: + spec["serviceName"] = workload_name + spec["volumeClaimTemplates"] = [ + { + "metadata": { + "name": path_to_vn_name(path), + "annotations": {"path": path, "value": "1"}, + }, + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + }, + } + for path in mount_paths + ] + + return { + "apiVersion": "apps/v1", + "kind": kind, + "metadata": { + "name": workload_name, + "annotations": { + "originImageName": image, + "deploy.cloud.sealos.io/minReplicas": "1", + "deploy.cloud.sealos.io/maxReplicas": "1", + }, + "labels": { + "cloud.sealos.io/app-deploy-manager": workload_name, + "app": workload_name, + }, + }, + "spec": spec, + } + + +def build_service(workload_name: str, ports: Sequence[int]) -> Optional[Dict[str, Any]]: + if not ports: + return None + service_ports = [{"name": f"tcp-{p}", "port": p, "targetPort": p, "protocol": "TCP"} for p in ports] + return { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": workload_name, + "labels": { + "app": workload_name, + "cloud.sealos.io/app-deploy-manager": workload_name, + }, + }, + "spec": { + "ports": service_ports, + "selector": {"app": workload_name}, + }, + } + + +def build_ingress(primary_workload_name: str, port: int) -> Dict[str, Any]: + return { + "apiVersion": "networking.k8s.io/v1", + "kind": "Ingress", + "metadata": { + "name": primary_workload_name, + "labels": { + "cloud.sealos.io/app-deploy-manager": primary_workload_name, + "cloud.sealos.io/app-deploy-manager-domain": "${{ defaults.app_host }}", + }, + "annotations": { + **HTTP_INGRESS_ANNOTATIONS, + }, + }, + "spec": { + "rules": [ + { + "host": "${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}", + "http": { + "paths": [ + { + "pathType": "Prefix", + "path": "/", + "backend": { + "service": { + "name": primary_workload_name, + "port": {"number": port}, + } + }, + } + ] + }, + } + ], + "tls": [ + { + "hosts": ["${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}"], + "secretName": "${{ SEALOS_CERT_SECRET_NAME }}", + } + ], + }, + } + + +def build_app_resource(meta: MetadataOptions) -> Dict[str, Any]: + return { + "apiVersion": "app.sealos.io/v1", + "kind": "App", + "metadata": { + "name": "${{ defaults.app_name }}", + "labels": { + "cloud.sealos.io/app-deploy-manager": "${{ defaults.app_name }}", + }, + }, + "spec": { + "data": { + "url": "https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}", + }, + "displayType": "normal", + "icon": f"{meta.repo_raw_base}/template/{meta.app_name}/logo.png", + "name": meta.title, + "type": "link", + }, + } + + +def iter_services(compose_data: Mapping[str, Any]) -> Iterable[Tuple[str, Mapping[str, Any]]]: + services = compose_data.get("services") + assert isinstance(services, dict) + for name, service in services.items(): + if isinstance(service, dict): + yield str(name), service + + +def validate_images(compose_data: Mapping[str, Any]) -> Dict[str, str]: + normalized_images: Dict[str, str] = {} + for service_name, service in iter_services(compose_data): + image = service.get("image") + if not isinstance(image, str) or not image.strip(): + raise ValueError(f"service {service_name!r} must define image") + normalized = normalize_image_reference(image, service_name) + normalized_images[service_name] = normalized + if is_latest_image(normalized): + raise ValueError(f"service {service_name!r} uses forbidden ':latest' tag") + if not has_pinned_image(normalized): + raise ValueError( + f"service {service_name!r} uses unpinned image {normalized!r}; provide a fixed tag or digest" + ) + return normalized_images + + +def render_index_yaml(documents: Sequence[Mapping[str, Any]]) -> str: + parts = [yaml.safe_dump(doc, sort_keys=False, allow_unicode=True).rstrip() for doc in documents] + return "\n---\n".join(parts) + "\n" + + +def build_documents( + compose_data: Mapping[str, Any], + meta: MetadataOptions, + kompose_shapes: Optional[Mapping[str, ServiceShape]] = None, +) -> List[Dict[str, Any]]: + normalized_images = validate_images(compose_data) + service_items = list(iter_services(compose_data)) + if not service_items: + raise ValueError("compose file has no services") + + digest_cache: Dict[str, str] = {} + tag_cache: Dict[str, List[str]] = {} + resolved_images: Dict[str, str] = {} + for service_name, service in service_items: + source_image = normalized_images.get(service_name, str(service.get("image", "")).strip()) + if not source_image: + continue + if detect_db_type(source_image) in SPECIAL_DB_RESOURCE_TYPES: + resolved_images[service_name] = source_image + continue + resolved_images[service_name] = resolve_image_reference( + source_image, + digest_cache=digest_cache, + tag_cache=tag_cache, + ) + + db_services: Dict[str, str] = {} + app_services: List[Tuple[str, Mapping[str, Any]]] = [] + gateway_services: List[Tuple[str, Mapping[str, Any]]] = [] + for name, service in service_items: + image = resolved_images.get(name, str(service.get("image", ""))) + db_type = detect_db_type(image) + if db_type in SPECIAL_DB_RESOURCE_TYPES: + db_services[name] = db_type + else: + if is_platform_edge_gateway_service(name, service, image): + gateway_services.append((name, service)) + else: + app_services.append((name, service)) + + if not app_services: + if gateway_services: + app_services = gateway_services[:1] + else: + app_services = service_items[:1] + + db_hosts = {name: DB_FQDN_BY_TYPE[db_type] for name, db_type in db_services.items() if db_type in DB_FQDN_BY_TYPE} + pull_secret_name = "${{ defaults.app_name }}" + + docs: List[Dict[str, Any]] = [] + docs.append(build_template_resource(meta)) + + all_env_keys = set() + for _, service in app_services: + for key, _ in parse_env(service): + all_env_keys.add(key) + if OBJECT_STORAGE_BUCKET_ENV_NAME in all_env_keys or OBJECT_STORAGE_BASE_ENV_NAMES.intersection(all_env_keys): + docs.append(build_object_storage_bucket()) + + ordered_db_types: List[str] = [] + for service_name, _ in service_items: + db_type = db_services.get(service_name) + if not isinstance(db_type, str): + continue + if db_type in ordered_db_types: + continue + ordered_db_types.append(db_type) + + for db_type in ordered_db_types: + docs.extend(build_database_resources(db_type)) + + workload_docs: List[Dict[str, Any]] = [] + service_docs: List[Dict[str, Any]] = [] + primary_port: Optional[int] = None + primary_workload_name = "${{ defaults.app_name }}" + for index, (service_name, service) in enumerate(app_services): + workload_name = ( + primary_workload_name + if index == 0 + else f"${{{{ defaults.app_name }}}}-{normalize_k8s_name(service_name)}" + ) + image = resolved_images.get(service_name, str(service["image"]).strip()) + ports = parse_ports(service) + env_entries = build_env_entries(service, db_hosts, db_services) + command_args = parse_command_args(service) + mount_paths = parse_mount_paths(service) + if kompose_shapes: + shape = kompose_shapes.get(normalize_k8s_name(service_name)) + if shape is not None: + if not ports: + ports = list(shape.ports) + if not mount_paths: + mount_paths = list(shape.mount_paths) + ports = normalize_ports_for_gateway_tls_termination(ports) + probes = build_probe_pair(service, image, ports, command_args) + workload = build_workload( + workload_name=workload_name, + pull_secret_name=pull_secret_name, + image=image, + ports=ports, + env_entries=env_entries, + command_args=command_args, + mount_paths=mount_paths, + probes=probes, + ) + workload_docs.append(workload) + service_doc = build_service(workload_name, ports) + if service_doc is not None: + service_docs.append(service_doc) + if index == 0 and ports: + primary_port = ports[0] + + docs.extend(workload_docs) + docs.extend(service_docs) + if primary_port is not None: + docs.append(build_ingress(primary_workload_name, primary_port)) + docs.append(build_app_resource(meta)) + return docs + + +def convert_compose_to_template( + *, + compose_path: Path, + output_root: Path, + meta: MetadataOptions, + kompose_shapes: Optional[Mapping[str, ServiceShape]] = None, + write_files: bool = True, +) -> Tuple[Path, str]: + compose_data = parse_compose(compose_path) + documents = build_documents(compose_data, meta, kompose_shapes=kompose_shapes) + app_dir = output_root / meta.app_name + index_path = app_dir / "index.yaml" + rendered = render_index_yaml(documents) + if write_files: + app_dir.mkdir(parents=True, exist_ok=True) + index_path.write_text(rendered, encoding="utf-8") + return index_path, rendered + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Convert Docker Compose to Sealos template deterministically") + parser.add_argument("--compose", required=True, help="Path to docker-compose YAML") + parser.add_argument("--output-dir", default="template", help="Output template root directory") + parser.add_argument("--app-name", default="", help="Template app name (lowercase k8s format)") + parser.add_argument("--title", default="", help="Template title") + parser.add_argument("--description", default="", help="Template description") + parser.add_argument("--url", default="", help="Official app URL") + parser.add_argument("--git-repo", default="", help="Source repository URL") + parser.add_argument("--author", default="Sealos", help="Template author") + parser.add_argument("--category", action="append", default=[], help="Template category (repeatable)") + parser.add_argument( + "--repo-raw-base", + default="https://raw.githubusercontent.com/labring-actions/templates/kb-0.9", + help="Raw repository base URL for icon fields", + ) + parser.add_argument( + "--kompose-mode", + choices=("auto", "always", "never"), + default="always", + help="Use kompose-generated workload shapes: always (required, default), auto (best effort), never (disable)", + ) + parser.add_argument("--dry-run", action="store_true", help="Print index.yaml content without writing files") + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + compose_path = Path(args.compose).resolve() + if not compose_path.exists(): + raise SystemExit(f"ERROR: compose file not found: {compose_path}") + + compose_data = parse_compose(compose_path) + meta = infer_metadata(args, compose_data, compose_path) + output_root = Path(args.output_dir).resolve() + + try: + kompose_shapes = resolve_kompose_shapes(compose_path, args.kompose_mode) + index_path, rendered = convert_compose_to_template( + compose_path=compose_path, + output_root=output_root, + meta=meta, + kompose_shapes=kompose_shapes, + write_files=not args.dry_run, + ) + except ValueError as exc: + raise SystemExit(f"ERROR: {exc}") from exc + + if args.dry_run: + print(rendered) + else: + print(f"Generated: {index_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/path_converter.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/path_converter.py new file mode 100644 index 00000000..19a5092a --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/path_converter.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Sealos Path to vn- Name Converter + +This script converts file paths to Sealos vn- naming convention for use in: +- ConfigMap data keys +- Volume names +- VolumeClaimTemplates metadata names + +Conversion rules: +- Convert to lowercase +- Replace every non [a-z0-9] character sequence with 'vn-' +- Prefix the final name with 'vn-' +- Reject empty or non-alphanumeric-only paths +- Truncate names longer than 63 chars with a stable hash suffix +""" + +import re +import sys +import hashlib + + +MAX_K8S_NAME_LEN = 63 + + +def _normalize_path_to_suffix(path: str) -> str: + """ + Normalize a path-like string to a Kubernetes-safe suffix. + + Rules: + - Trim surrounding whitespace + - Lowercase all characters + - Treat any non [a-z0-9] character as a separator + - Join segments with "vn-" + """ + if path is None: + raise ValueError("Path cannot be None") + + raw = path.strip() + if not raw: + raise ValueError("Path cannot be empty") + + # Special-case root-like paths ("/", "////", etc.) + if re.fullmatch(r"/+", raw): + return "root" + + normalized = raw.strip("/").lower() + segments = [seg for seg in re.split(r"[^a-z0-9]+", normalized) if seg] + if not segments: + raise ValueError("Path must contain at least one alphanumeric character") + + return "vn-".join(segments) + + +def _truncate_with_hash(name: str, original: str) -> str: + """ + Ensure the generated name does not exceed Kubernetes DNS-1123 label length. + """ + if len(name) <= MAX_K8S_NAME_LEN: + return name + + digest = hashlib.sha1(original.encode("utf-8")).hexdigest()[:8] + keep_len = MAX_K8S_NAME_LEN - len(digest) - 1 # "-" + hash + prefix = name[:keep_len].rstrip("-") + if not prefix: + prefix = "vn" + + return f"{prefix}-{digest}" + + +def path_to_vn_name(path: str) -> str: + """ + Convert a file path to Sealos vn- naming convention. + + Args: + path: File path (e.g., "/etc/nginx/conf.d/default.conf") + + Returns: + vn- formatted, Kubernetes-safe name. + + Examples: + >>> path_to_vn_name("/etc/nginx/conf.d/default.conf") + 'vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf' + + >>> path_to_vn_name("/var/lib/headscale") + 'vn-varvn-libvn-headscale' + + >>> path_to_vn_name("/app/config.yml") + 'vn-appvn-configvn-yml' + + >>> path_to_vn_name("/var/lib/My_App") + 'vn-varvn-libvn-myvn-app' + """ + suffix = _normalize_path_to_suffix(path) + vn_name = f"vn-{suffix}" + return _truncate_with_hash(vn_name, path) + + +def vn_name_to_path(vn_name: str) -> str: + """ + Convert a vn- name back to a file path (best effort). + Note: This is ambiguous as we can't determine if 'vn-' was originally '/', '-', or '.' + + Args: + vn_name: vn- formatted name + + Returns: + Approximate file path + """ + # Remove leading 'vn-' + if vn_name.startswith('vn-'): + vn_name = vn_name[3:] + + # Replace 'vn-' with '/' (most common case) + path = vn_name.replace('vn-', '/') + + # Add leading '/' + if not path.startswith('/'): + path = '/' + path + + return path + + +def run_self_test() -> int: + """Run a minimal regression suite for conversion edge cases.""" + cases = [ + ("/etc/nginx/nginx.conf", "vn-etcvn-nginxvn-nginxvn-conf"), + ("/var/lib/My_App", "vn-varvn-libvn-myvn-app"), + ("/data/cache@prod", "vn-datavn-cachevn-prod"), + ("/", "vn-root"), + ] + + for raw, expected in cases: + actual = path_to_vn_name(raw) + if actual != expected: + print(f"FAIL: {raw} -> {actual}, expected {expected}") + return 1 + + for raw in ("", "____"): + try: + path_to_vn_name(raw) + except ValueError: + pass + else: + print(f"FAIL: expected ValueError for input: {raw!r}") + return 1 + + print("Self-test passed.") + return 0 + + +def main(): + """Command-line interface for path conversion.""" + if len(sys.argv) < 2: + print("Usage:") + print(" Convert path to vn-name:") + print(" python path_converter.py /etc/nginx/conf.d/default.conf") + print() + print(" Run self-test:") + print(" python path_converter.py --self-test") + print() + print(" Convert vn-name to path:") + print(" python path_converter.py --reverse vn-etcvn-nginxvn-confvn-dvn-defaultvn-conf") + sys.exit(1) + + try: + if sys.argv[1] == '--self-test': + sys.exit(run_self_test()) + if sys.argv[1] == '--reverse': + if len(sys.argv) < 3: + print("Error: Please provide a vn-name to convert") + sys.exit(1) + vn_name = sys.argv[2] + result = vn_name_to_path(vn_name) + print(f"vn-name: {vn_name}") + print(f"Path: {result}") + else: + path = sys.argv[1] + result = path_to_vn_name(path) + print(f"Path: {path}") + print(f"vn-name: {result}") + except ValueError as exc: + print(f"Error: {exc}") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/quality_gate.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/quality_gate.py new file mode 100644 index 00000000..13e850b1 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/quality_gate.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Single-entry quality gate for docker-to-sealos skill checks.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import List, Sequence, Tuple + + +def _resolve_artifact_targets(root: Path) -> str: + configured = os.environ.get("DOCKER_TO_SEALOS_ARTIFACTS", "").strip() + if configured: + return configured + + template_dir = root / "template" + if not template_dir.exists(): + return "" + + index_files = sorted(template_dir.rglob("index.yaml")) + if index_files: + return ",".join(str(path) for path in index_files) + return str(template_dir) + + +def _allow_empty_artifacts() -> bool: + value = os.environ.get("DOCKER_TO_SEALOS_ALLOW_EMPTY_ARTIFACTS", "").strip().lower() + return value in {"1", "true", "yes", "on"} + + +def validate_artifact_targets(artifacts: str, allow_empty: bool) -> Tuple[bool, str]: + if artifacts: + return True, "" + if allow_empty: + return ( + True, + "[WARN] no template artifacts found; skipping artifact checks due to DOCKER_TO_SEALOS_ALLOW_EMPTY_ARTIFACTS", + ) + return ( + False, + "[FAIL] no template artifacts found; expected template/*/index.yaml or DOCKER_TO_SEALOS_ARTIFACTS", + ) + + +def build_commands(root: Path, artifacts: str) -> List[Tuple[str, Sequence[str]]]: + scripts_dir = root / "scripts" + python = sys.executable + consistency_command = [ + python, + str(scripts_dir / "check_consistency.py"), + "--skill", + str(root / "SKILL.md"), + "--references", + str(root / "references"), + "--rules-file", + str(root / "references" / "rules-registry.yaml"), + ] + if artifacts: + consistency_command.extend(["--artifacts", artifacts]) + + return [ + ( + "path converter self-test", + (python, str(scripts_dir / "path_converter.py"), "--self-test"), + ), + ( + "consistency validator tests", + (python, str(scripts_dir / "test_check_consistency.py")), + ), + ( + "compose converter tests", + (python, str(scripts_dir / "test_compose_to_template.py")), + ), + ( + "must coverage validator tests", + (python, str(scripts_dir / "test_check_must_coverage.py")), + ), + ( + "rules consistency check", + tuple(consistency_command), + ), + ( + "must coverage check", + ( + python, + str(scripts_dir / "check_must_coverage.py"), + "--skill", + str(root / "SKILL.md"), + "--mapping", + str(root / "references" / "must-rules-map.yaml"), + "--rules-file", + str(root / "references" / "rules-registry.yaml"), + ), + ), + ] + + +def main() -> int: + root = Path(__file__).resolve().parent.parent + artifacts = _resolve_artifact_targets(root) + ok, message = validate_artifact_targets(artifacts, _allow_empty_artifacts()) + if message: + print(message) + if not ok: + return 2 + + for title, command in build_commands(root, artifacts): + print(f"[RUN] {title}") + result = subprocess.run(command, cwd=root) + if result.returncode != 0: + print(f"[FAIL] {title}") + return result.returncode + + print("[PASS] quality gate complete") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_check_consistency.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_check_consistency.py new file mode 100644 index 00000000..c6044f06 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_check_consistency.py @@ -0,0 +1,2803 @@ +#!/usr/bin/env python3 +import importlib.util +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path +from typing import Any, Dict, Optional + +from check_consistency_line_locator import LineLocator +from check_consistency_rule_helpers import iter_containers as legacy_iter_containers +from check_consistency_helpers_workload import iter_containers + + +MODULE_PATH = Path(__file__).resolve().parent / "check_consistency.py" +MODULE_SPEC = importlib.util.spec_from_file_location("check_consistency", MODULE_PATH) +CHECKER = importlib.util.module_from_spec(MODULE_SPEC) +sys.modules[MODULE_SPEC.name] = CHECKER +assert MODULE_SPEC.loader is not None +MODULE_SPEC.loader.exec_module(CHECKER) + + +def write_file(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content).lstrip("\n"), encoding="utf-8") + + +def render_registry( + overrides: Optional[Dict[str, Dict[str, Any]]] = None, + include_paths: Optional[list[str]] = None, +) -> str: + overrides = overrides or {} + include_paths = include_paths or ["SKILL.md", "references"] + + lines = ["version: 1", "scope:", " include:"] + for path in include_paths: + lines.append(f" - {path}") + lines.append("rules:") + + for rule_id in sorted(CHECKER.REGISTERED_RULES.keys()): + rule_override = overrides.get(rule_id, {}) + lines.append(f" - id: {rule_id}") + lines.append(" description: test") + lines.append(f" severity: {rule_override.get('severity', 'error')}") + scope_paths = rule_override.get("include_paths") + if scope_paths is not None: + lines.append(" scope:") + lines.append(" include_paths:") + for scope_path in scope_paths: + lines.append(f" - {scope_path}") + + return "\n".join(lines) + "\n" + + +def write_registry(path: Path) -> None: + path.write_text(render_registry(), encoding="utf-8") + + +class CheckConsistencyTests(unittest.TestCase): + def run_checker( + self, + skill_text: str, + refs_text: str = "# refs\n", + rules_override: Optional[str] = None, + additional_include_paths: Optional[list[str]] = None, + ): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + + write_file(skill, skill_text) + write_file(refs_file, refs_text) + if rules_override is None: + write_registry(rules_file) + else: + write_file(rules_file, rules_override) + + return CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=additional_include_paths, + ) + + def test_detects_app_spec_template_with_long_gap(self): + long_gap = "x" * 1200 + violations = self.run_checker( + f""" + ```yaml + apiVersion: app.sealos.io/v1 + kind: App + metadata: + name: app-demo + annotations: + note: "{long_gap}" + spec: + template: + url: https://bad.example.com + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R002" for item in violations)) + + def test_ignores_parse_errors_for_template_control_snippets(self): + violations = self.run_checker( + """ + ```yaml + ${{ if(inputs.enableIngress === 'true') }} + apiVersion: apps/v1 + kind: Deployment + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ${{ endif() }} + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R000" for item in violations)) + + def test_ignores_parse_errors_for_ellipsis_snippets(self): + violations = self.run_checker( + """ + ```yaml + ... + spec: + ... + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R000" for item in violations)) + + def test_detects_missing_app_data_url(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: app.sealos.io/v1 + kind: App + metadata: + name: app-demo + spec: + data: {} + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R003" for item in violations)) + + def test_detects_missing_app_display_type(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: app.sealos.io/v1 + kind: App + metadata: + name: app-demo + spec: + data: + url: https://demo.example.com + type: link + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R032" for item in violations)) + + def test_detects_invalid_app_display_type(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: app.sealos.io/v1 + kind: App + metadata: + name: app-demo + spec: + data: + url: https://demo.example.com + displayType: standalone + type: link + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R032" for item in violations)) + + def test_detects_missing_app_type(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: app.sealos.io/v1 + kind: App + metadata: + name: app-demo + spec: + data: + url: https://demo.example.com + displayType: normal + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R033" for item in violations)) + + def test_detects_invalid_app_type(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: app.sealos.io/v1 + kind: App + metadata: + name: app-demo + spec: + data: + url: https://demo.example.com + displayType: normal + type: web + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R033" for item in violations)) + + def test_detects_template_name_variable(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: ${{ defaults.app_name }} + spec: + title: Demo + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R004" for item in violations)) + + def test_detects_template_required_metadata_fields_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo + spec: + title: Demo + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R012" for item in violations)) + + def test_detects_template_folder_name_mismatch_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo-app + spec: + title: Demo + url: https://demo.example.com + gitRepo: https://github.com/example/demo + author: example + description: demo + icon: https://raw.githubusercontent.com/example/demo/kb-0.9/template/demo-app/logo.png + templateType: inline + locale: en + i18n: + en: + title: Demo + categories: + - ai + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R013" for item in violations)) + + def test_detects_template_icon_path_mismatch_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo + spec: + title: Demo + url: https://demo.example.com + gitRepo: https://github.com/example/demo + author: example + description: demo + icon: https://avatars.githubusercontent.com/u/123?v=4 + templateType: inline + locale: en + i18n: + en: + title: Demo + categories: + - ai + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R014" for item in violations)) + + def test_detects_template_readme_path_mismatch_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo + spec: + title: Demo + url: https://demo.example.com + gitRepo: https://github.com/example/demo + author: example + description: demo + readme: https://raw.githubusercontent.com/example/demo/main/README.md + icon: https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template/demo/logo.png + templateType: inline + locale: en + i18n: + zh: + description: 演示应用模板 + readme: https://raw.githubusercontent.com/example/demo/main/README_zh.md + categories: + - ai + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R025" for item in violations)) + + def test_allows_template_with_expected_readme_paths_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo + spec: + title: Demo + url: https://demo.example.com + gitRepo: https://github.com/example/demo + author: example + description: demo + readme: https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template/demo/README.md + icon: https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template/demo/logo.png + templateType: inline + locale: en + i18n: + zh: + description: 演示应用模板 + readme: https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template/demo/README_zh.md + categories: + - ai + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertFalse(any(item.rule_id == "R025" for item in violations)) + + def test_detects_non_chinese_i18n_zh_description_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo + spec: + title: Demo + url: https://demo.example.com + gitRepo: https://github.com/example/demo + author: example + description: demo + icon: https://raw.githubusercontent.com/example/demo/kb-0.9/template/demo/logo.png + templateType: inline + locale: en + i18n: + zh: + description: Demo template + categories: + - ai + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R021" for item in violations)) + + def test_detects_redundant_i18n_zh_title_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo + spec: + title: Demo + url: https://demo.example.com + gitRepo: https://github.com/example/demo + author: example + description: demo + icon: https://raw.githubusercontent.com/example/demo/kb-0.9/template/demo/logo.png + templateType: inline + locale: en + i18n: + zh: + title: Demo + description: 示例应用模板 + categories: + - ai + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R022" for item in violations)) + + def test_allows_template_with_chinese_i18n_zh_description_and_no_zh_title(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo + spec: + title: Demo + url: https://demo.example.com + gitRepo: https://github.com/example/demo + author: example + description: demo + icon: https://raw.githubusercontent.com/example/demo/kb-0.9/template/demo/logo.png + templateType: inline + locale: en + i18n: + zh: + description: 演示应用模板 + categories: + - ai + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertFalse(any(item.rule_id in {"R021", "R022"} for item in violations)) + + def test_detects_invalid_template_categories_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo + spec: + title: Demo + url: https://demo.example.com + gitRepo: https://github.com/example/demo + author: example + description: demo + icon: https://raw.githubusercontent.com/example/demo/kb-0.9/template/demo/logo.png + templateType: inline + locale: en + i18n: + zh: + description: 演示应用模板 + categories: + - tool + - security + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R023" for item in violations)) + + def test_allows_valid_template_categories_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo + spec: + title: Demo + url: https://demo.example.com + gitRepo: https://github.com/example/demo + author: example + description: demo + icon: https://raw.githubusercontent.com/example/demo/kb-0.9/template/demo/logo.png + templateType: inline + locale: en + i18n: + zh: + description: 演示应用模板 + categories: + - tool + - backend + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertFalse(any(item.rule_id == "R023" for item in violations)) + + def test_detects_missing_official_health_probes_for_authentik_server(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: authentik + labels: + cloud.sealos.io/app-deploy-manager: authentik + annotations: + originImageName: ghcr.io/goauthentik/server:2025.12.3 + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: authentik + image: ghcr.io/goauthentik/server:2025.12.3 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R024" for item in violations)) + + def test_allows_official_health_probes_for_authentik_server(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: authentik + labels: + cloud.sealos.io/app-deploy-manager: authentik + annotations: + originImageName: ghcr.io/goauthentik/server:2025.12.3 + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: authentik + image: ghcr.io/goauthentik/server:2025.12.3 + imagePullPolicy: IfNotPresent + livenessProbe: + httpGet: + path: /-/health/live/ + port: 9000 + readinessProbe: + httpGet: + path: /-/health/ready/ + port: 9000 + startupProbe: + httpGet: + path: /-/health/ready/ + port: 9000 + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R024" for item in violations)) + + def test_detects_missing_startup_probe_for_authentik_server(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: authentik + labels: + cloud.sealos.io/app-deploy-manager: authentik + annotations: + originImageName: ghcr.io/goauthentik/server:2025.12.3 + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: authentik + image: ghcr.io/goauthentik/server:2025.12.3 + imagePullPolicy: IfNotPresent + livenessProbe: + httpGet: + path: /-/health/live/ + port: 9000 + readinessProbe: + httpGet: + path: /-/health/ready/ + port: 9000 + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R024" for item in violations)) + + def test_detects_origin_image_name_mismatch_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + annotations: + originImageName: nginx:1.27.2 + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: nginx:1.27.3 + imagePullPolicy: IfNotPresent + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R015" for item in violations)) + + def test_detects_latest_tag(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + spec: + template: + spec: + containers: + - name: demo + image: nginx:latest + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R001" for item in violations)) + + def test_detects_floating_tag_for_managed_workload(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + annotations: + originImageName: ghcr.io/example/demo:v2 + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: ghcr.io/example/demo:v2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R016" for item in violations)) + + def test_allows_explicit_version_tag_for_managed_workload(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + annotations: + originImageName: ghcr.io/example/demo:v2.2.0 + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: ghcr.io/example/demo:v2.2.0 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R016" for item in violations)) + + def test_detects_compose_image_variables_for_managed_workload(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + annotations: + originImageName: ${APP_IMAGE:-ghcr.io/example/demo}:${APP_TAG:-1.2.3} + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: ${APP_IMAGE:-ghcr.io/example/demo}:${APP_TAG:-1.2.3} + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R018" for item in violations)) + + def test_detects_service_ports_missing_names_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: v1 + kind: Service + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + spec: + ports: + - port: 9000 + targetPort: 9000 + protocol: TCP + - port: 9443 + targetPort: 9443 + protocol: TCP + selector: + app: demo + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R020" for item in violations)) + + def test_detects_service_missing_required_labels_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: v1 + kind: Service + metadata: + name: demo-svc + spec: + ports: + - name: tcp-8080 + port: 8080 + targetPort: 8080 + protocol: TCP + selector: + app: demo + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R029" for item in violations)) + + def test_detects_service_label_mismatch_against_selector_app_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: v1 + kind: Service + metadata: + name: demo-svc + labels: + app: wrong + cloud.sealos.io/app-deploy-manager: wrong + spec: + ports: + - name: tcp-8080 + port: 8080 + targetPort: 8080 + protocol: TCP + selector: + app: demo + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R029" for item in violations)) + + def test_allows_service_labels_matching_selector_app_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: v1 + kind: Service + metadata: + name: demo + labels: + app: demo + cloud.sealos.io/app-deploy-manager: demo + spec: + ports: + - name: tcp-8080 + port: 8080 + targetPort: 8080 + protocol: TCP + selector: + app: demo + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertFalse(any(item.rule_id == "R029" for item in violations)) + + def test_detects_configmap_missing_component_labels_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: v1 + kind: ConfigMap + metadata: + name: demo + data: + key: value + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R030" for item in violations)) + + def test_detects_ingress_backend_name_mismatch_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + spec: + rules: + - host: demo.example.com + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: demo-web + port: + number: 8080 + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R031" for item in violations)) + + def test_allows_ingress_backend_name_match_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + spec: + rules: + - host: demo.example.com + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: demo + port: + number: 8080 + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertFalse(any(item.rule_id == "R031" for item in violations)) + + def test_detects_missing_http_ingress_annotations_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: demo + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/backend-protocol: HTTP + spec: + rules: + - host: demo.example.com + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: demo + port: + number: 8080 + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R026" for item in violations)) + + def test_allows_required_http_ingress_annotations_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: demo + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: 32m + nginx.ingress.kubernetes.io/server-snippet: | + client_header_buffer_size 64k; + large_client_header_buffers 4 128k; + nginx.ingress.kubernetes.io/ssl-redirect: 'true' + nginx.ingress.kubernetes.io/backend-protocol: HTTP + nginx.ingress.kubernetes.io/client-body-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-buffer-size: 64k + nginx.ingress.kubernetes.io/proxy-send-timeout: '300' + nginx.ingress.kubernetes.io/proxy-read-timeout: '300' + nginx.ingress.kubernetes.io/configuration-snippet: | + if ($request_uri ~* \.(js|css|gif|jpe?g|png)) { + expires 30d; + add_header Cache-Control "public"; + } + spec: + rules: + - host: demo.example.com + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: demo + port: + number: 8080 + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertFalse(any(item.rule_id == "R026" for item in violations)) + + def test_detects_missing_pg_init_job_for_custom_postgres_database(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: apps.kubeblocks.io/v1alpha1 + kind: Cluster + metadata: + name: demo-pg + labels: + kb.io/database: postgresql-16.4.0 + clusterdefinition.kubeblocks.io/name: postgresql + spec: + clusterDefinitionRef: postgresql + clusterVersionRef: postgresql-16.4.0 + componentSpecs: [] + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + annotations: + originImageName: ghcr.io/posthog/posthog:1.0.0 + spec: + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: ghcr.io/posthog/posthog:1.0.0 + imagePullPolicy: IfNotPresent + env: + - name: DATABASE_URL + value: postgres://user:pass@demo-pg:5432/posthog + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R027" for item in violations)) + + def test_detects_non_robust_pg_init_job_for_custom_postgres_database(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: apps.kubeblocks.io/v1alpha1 + kind: Cluster + metadata: + name: demo-pg + labels: + kb.io/database: postgresql-16.4.0 + clusterdefinition.kubeblocks.io/name: postgresql + spec: + clusterDefinitionRef: postgresql + clusterVersionRef: postgresql-16.4.0 + componentSpecs: [] + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + annotations: + originImageName: ghcr.io/posthog/posthog:1.0.0 + spec: + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: ghcr.io/posthog/posthog:1.0.0 + imagePullPolicy: IfNotPresent + env: + - name: DATABASE_URL + value: postgres://user:pass@demo-pg:5432/posthog + --- + apiVersion: batch/v1 + kind: Job + metadata: + name: demo-pg-init + spec: + template: + spec: + containers: + - name: init + image: postgres:16.4 + imagePullPolicy: IfNotPresent + command: + - sh + - -c + - | + psql "postgresql://postgres:pwd@demo-pg:5432/postgres" -c 'CREATE DATABASE posthog;' + restartPolicy: OnFailure + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R027" for item in violations)) + + def test_allows_robust_pg_init_job_for_custom_postgres_database(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: apps.kubeblocks.io/v1alpha1 + kind: Cluster + metadata: + name: demo-pg + labels: + kb.io/database: postgresql-16.4.0 + clusterdefinition.kubeblocks.io/name: postgresql + spec: + clusterDefinitionRef: postgresql + clusterVersionRef: postgresql-16.4.0 + componentSpecs: [] + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + annotations: + originImageName: ghcr.io/posthog/posthog:1.0.0 + spec: + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: ghcr.io/posthog/posthog:1.0.0 + imagePullPolicy: IfNotPresent + env: + - name: DATABASE_URL + value: postgres://user:pass@demo-pg:5432/posthog + --- + apiVersion: batch/v1 + kind: Job + metadata: + name: demo-pg-init + spec: + template: + spec: + containers: + - name: init + image: postgres:16.4 + imagePullPolicy: IfNotPresent + command: + - sh + - -c + - | + set -eu + pg_isready -h demo-pg -p 5432 -U postgres -d postgres >/dev/null 2>&1 + if ! psql "postgresql://postgres:pwd@demo-pg:5432/postgres" -tAc "SELECT 1 FROM pg_database WHERE datname='posthog'" | grep -q 1; then + createdb -h demo-pg -p 5432 -U postgres posthog + fi + restartPolicy: OnFailure + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertFalse(any(item.rule_id == "R027" for item in violations)) + + def test_detects_postgres_secret_ref_not_matching_cluster_name(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: apps.kubeblocks.io/v1alpha1 + kind: Cluster + metadata: + name: demo-postgres + labels: + kb.io/database: postgresql-16.4.0 + clusterdefinition.kubeblocks.io/name: postgresql + spec: + clusterDefinitionRef: postgresql + clusterVersionRef: postgresql-16.4.0 + componentSpecs: [] + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + annotations: + originImageName: ghcr.io/example/demo:1.0.0 + spec: + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: ghcr.io/example/demo:1.0.0 + imagePullPolicy: IfNotPresent + env: + - name: POSTGRES_HOST + valueFrom: + secretKeyRef: + name: demo-pg-conn-credential + key: host + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R037" for item in violations)) + + def test_detects_missing_cronjob_required_labels(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: batch/v1 + kind: CronJob + metadata: + name: demo-task + spec: + schedule: "* * * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: demo-task + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + restartPolicy: OnFailure + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R036" for item in violations)) + + def test_allows_cronjob_required_labels(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: batch/v1 + kind: CronJob + metadata: + name: demo-task + labels: + cloud.sealos.io/cronjob: demo-task + cronjob-launchpad-name: "" + cronjob-type: image + spec: + schedule: "* * * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: demo-task + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + restartPolicy: OnFailure + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R036" for item in violations)) + + def test_ignores_latest_tag_in_negative_example_block(self): + violations = self.run_checker( + """ + wrong example + ```yaml + apiVersion: apps/v1 + kind: Deployment + spec: + template: + spec: + containers: + - name: demo + image: nginx:latest + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R001" for item in violations)) + + def test_detects_empty_dir(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + spec: + template: + spec: + volumes: + - name: temp + emptyDir: {} + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R005" for item in violations)) + + def test_detects_missing_image_pull_policy(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R006" for item in violations)) + + def test_detects_business_secret_ref(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: API_TOKEN + valueFrom: + secretKeyRef: + name: custom-secret + key: token + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R007" for item in violations)) + + def test_detects_spoofed_database_secret_suffix(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: DB_PASS + valueFrom: + secretKeyRef: + name: totally-custom-mongodb-account-root + key: password + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R007" for item in violations)) + + def test_allows_approved_database_secret_name(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: DB_PASS + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-mongodb-account-root + key: password + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R007" for item in violations)) + self.assertFalse(any(item.rule_id == "R017" for item in violations)) + + def test_allows_new_mongodb_and_legacy_redis_secret_names(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: MONGO_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-mongo-mongodb-account-root + key: password + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-redis-account-default + key: password + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R007" for item in violations)) + self.assertFalse(any(item.rule_id == "R017" for item in violations)) + + def test_allows_redis_service_host_port_with_credential_secret(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: REDIS_HOST + value: ${{ defaults.app_name }}-redis-redis-redis.${{ SEALOS_NAMESPACE }}.svc.cluster.local + - name: REDIS_PORT + value: "6379" + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-redis-redis-account-default + key: password + - name: REDIS_URL + value: redis://:$(REDIS_PASSWORD)@$(REDIS_HOST):$(REDIS_PORT)/0 + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R007" for item in violations)) + self.assertFalse(any(item.rule_id == "R017" for item in violations)) + + def test_allows_mongodb_url_with_service_host_and_credential_secret(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: MONGO_USERNAME + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-mongo-mongodb-account-root + key: username + - name: MONGO_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-mongo-mongodb-account-root + key: password + - name: MONGODB_URI + value: mongodb://$(MONGO_USERNAME):$(MONGO_PASSWORD)@${{ defaults.app_name }}-mongo-mongodb.${{ SEALOS_NAMESPACE }}.svc:27017/demo?authSource=admin + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R007" for item in violations)) + self.assertFalse(any(item.rule_id == "R017" for item in violations)) + + def test_ignores_known_non_database_connection_env_names(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: POSTGREST_URL + value: http://postgrest:3000 + - name: PG_META_PORT + value: "8080" + - name: CODE_SANDBOX_URL + value: http://sandbox:8080 + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R017" for item in violations)) + + def test_detects_database_connection_env_without_secret_ref(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: AUTHENTIK_POSTGRESQL__HOST + value: ${{ defaults.app_name }}-pg-postgresql.${{ SEALOS_NAMESPACE }}.svc.cluster.local + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R017" for item in violations)) + + def test_detects_database_connection_env_with_mismatched_secret_key(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: DB_HOST + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R017" for item in violations)) + + def test_allows_database_connection_env_secret_fields(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: DB_ENDPOINT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: endpoint + - name: DB_HOST + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: host + - name: DB_PORT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: port + - name: DB_USERNAME + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R017" for item in violations)) + + def test_allows_composed_database_endpoint_with_secret_derived_components(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: DB_HOST + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: host + - name: DB_PORT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: port + - name: DB_USERNAME + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + - name: DATABASE_URL + value: postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/postgres + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R017" for item in violations)) + + def test_detects_composed_database_endpoint_with_non_secret_dependency(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: DB_HOST + value: postgres + - name: DB_PORT + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: port + - name: DATABASE_URL + value: postgres://$(DB_HOST):$(DB_PORT)/postgres + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R017" for item in violations)) + + def test_detects_reserved_database_secret_name_override(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: ${{ defaults.app_name }}-pg-conn-credential + type: Opaque + stringData: + password: fake + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: DB_PASS + valueFrom: + secretKeyRef: + name: ${{ defaults.app_name }}-pg-conn-credential + key: password + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R007" for item in violations)) + self.assertTrue(any("reserved" in item.message for item in violations if item.rule_id == "R007")) + + def test_allows_object_storage_secret_refs(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: S3_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: object-storage-key + key: accessKey + - name: S3_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: object-storage-key + key: secretKey + - name: BACKEND_STORAGE_MINIO_EXTERNAL_ENDPOINT + valueFrom: + secretKeyRef: + name: object-storage-key + key: external + - name: S3_BUCKET + valueFrom: + secretKeyRef: + name: object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }} + key: bucket + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R007" for item in violations)) + + def test_allows_object_storage_bucket_secret_with_suffix(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: STORAGE_PUBLIC_BUCKET + valueFrom: + secretKeyRef: + name: object-storage-key-${{ SEALOS_SERVICE_ACCOUNT }}-${{ defaults.app_name }}-public + key: bucket + ``` + """ + ) + self.assertFalse(any(item.rule_id == "R007" for item in violations)) + + def test_allows_registry_pull_secret_via_image_pull_secrets(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: ${{ defaults.app_name }} + type: kubernetes.io/dockerconfigjson + data: + .dockerconfigjson: e30= + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + app: demo + cloud.sealos.io/app-deploy-manager: demo + annotations: + originImageName: nginx:1.27.2 + spec: + revisionHistoryLimit: 1 + template: + metadata: + labels: + app: demo + spec: + automountServiceAccountToken: false + imagePullSecrets: + - name: ${{ defaults.app_name }} + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertFalse(any(item.rule_id in {"R007", "R035"} for item in violations)) + + def test_detects_missing_registry_pull_secret_reference(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + app: demo + cloud.sealos.io/app-deploy-manager: demo + annotations: + originImageName: nginx:1.27.2 + spec: + revisionHistoryLimit: 1 + template: + metadata: + labels: + app: demo + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R035" for item in violations)) + + def test_detects_object_storage_secret_misuse_on_non_s3_env(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + env: + - name: API_TOKEN + valueFrom: + secretKeyRef: + name: object-storage-key + key: accessKey + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R007" for item in violations)) + + def test_detects_env_from_secret_ref(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + envFrom: + - secretRef: + name: custom-envfrom-secret + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R007" for item in violations)) + + def test_detects_volume_secret_ref(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + volumes: + - name: certs + secret: + secretName: custom-volume-secret + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R007" for item in violations)) + + def test_detects_projected_secret_ref(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + volumes: + - name: mixed + projected: + sources: + - secret: + name: custom-projected-secret + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R007" for item in violations)) + + def test_detects_label_mismatch(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo-v2 + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R008" for item in violations)) + + def test_detects_missing_deploy_manager_label(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R008" for item in violations)) + + def test_detects_missing_app_label(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R034" for item in violations)) + + def test_detects_app_label_mismatch(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + app: demo-v2 + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R034" for item in violations)) + + def test_detects_container_name_mismatch(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + app: demo + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo-server + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R028" for item in violations)) + + def test_allows_matching_app_label_and_container_name(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + app: demo + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertFalse(any(item.rule_id in {"R034", "R028"} for item in violations)) + + def test_detects_missing_revision_history_limit(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + spec: + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R009" for item in violations)) + + def test_detects_missing_automount_service_account_token(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + spec: + revisionHistoryLimit: 1 + template: + spec: + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R010" for item in violations)) + + def test_detects_pvc_storage_over_limit(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + volumeClaimTemplates: + - metadata: + name: data + spec: + resources: + requests: + storage: 2Gi + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R011" for item in violations)) + + def test_detects_pvc_storage_variable_expression(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + volumeClaimTemplates: + - metadata: + name: data + spec: + resources: + requests: + storage: ${{ inputs.storage_size }} + ``` + """ + ) + self.assertTrue(any(item.rule_id == "R011" for item in violations)) + + def test_registry_rule_scope_filters_violations(self): + rules_yaml = render_registry( + overrides={ + "R001": { + "include_paths": ["references/*.md"], + } + } + ) + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + spec: + template: + spec: + containers: + - name: demo + image: nginx:latest + imagePullPolicy: IfNotPresent + ``` + """, + refs_text="# clean refs\n", + rules_override=rules_yaml, + ) + self.assertFalse(any(item.rule_id == "R001" for item in violations)) + + def test_detects_violations_in_generated_yaml_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + spec: + template: + spec: + containers: + - name: demo + image: nginx:latest + imagePullPolicy: IfNotPresent + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + latest_violations = [item for item in violations if item.rule_id == "R001"] + self.assertEqual(1, len(latest_violations)) + self.assertEqual(artifact_file.resolve(), latest_violations[0].path.resolve()) + + def test_detects_invalid_database_component_resources_in_artifact(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill = root / "SKILL.md" + refs_dir = root / "references" + refs_file = refs_dir / "sample.md" + rules_file = refs_dir / "rules-registry.yaml" + artifact_file = root / "template" / "demo" / "index.yaml" + + write_file(skill, "# no yaml snippets\n") + write_file(refs_file, "# refs\n") + write_registry(rules_file) + write_file( + artifact_file, + """ + apiVersion: apps.kubeblocks.io/v1alpha1 + kind: Cluster + metadata: + name: demo-pg + labels: + kb.io/database: postgresql-16.4.0 + spec: + componentSpecs: + - name: postgresql + resources: + limits: + cpu: 1000m + memory: 1024Mi + requests: + cpu: 100m + memory: 102Mi + """, + ) + + violations = CHECKER.run_checks( + skill, + refs_dir, + rules_file, + additional_include_paths=["template/demo/index.yaml"], + ) + self.assertTrue(any(item.rule_id == "R019" for item in violations)) + + def test_passes_managed_workload_baseline_controls(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demo + labels: + cloud.sealos.io/app-deploy-manager: demo + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + --- + apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: demo-data + labels: + cloud.sealos.io/app-deploy-manager: demo-data + spec: + revisionHistoryLimit: 1 + template: + spec: + automountServiceAccountToken: false + containers: + - name: demo + image: nginx:1.27.2 + imagePullPolicy: IfNotPresent + volumeClaimTemplates: + - metadata: + name: data + spec: + resources: + requests: + storage: 1Gi + ``` + """ + ) + self.assertFalse(any(item.rule_id in {"R009", "R010", "R011"} for item in violations)) + + def test_passes_minimal_compliant_docs(self): + violations = self.run_checker( + """ + ```yaml + apiVersion: app.sealos.io/v1 + kind: Template + metadata: + name: demo-app + spec: + title: Demo + --- + apiVersion: app.sealos.io/v1 + kind: App + metadata: + name: demo-app + labels: + cloud.sealos.io/app-deploy-manager: demo-app + spec: + data: + url: https://demo.example.com + displayType: normal + type: link + ``` + """ + ) + self.assertEqual([], violations) + + def test_registry_mismatch_raises(self): + with self.assertRaises(ValueError): + self.run_checker( + "# ok", + rules_override=""" + version: 1 + rules: + - id: R001 + description: test + severity: error + """, + ) + + def test_registry_invalid_severity_raises(self): + broken = render_registry(overrides={"R001": {"severity": "critical"}}) + with self.assertRaises(ValueError): + self.run_checker("# ok", rules_override=broken) + + +class ArchitectureRefactorTests(unittest.TestCase): + def test_line_locator_uses_index_for_simple_key_patterns(self): + locator = LineLocator( + start_line=20, + lines=( + "apiVersion: apps/v1", + "kind: Deployment", + "spec:", + " template:", + ), + ) + + self.assertEqual(22, locator.find(r"^\s*spec\s*:")) + self.assertEqual(23, locator.find(r"^\s*template\s*:")) + self.assertEqual(20, locator.find(r"^\s*metadata\s*:", default=20)) + + def test_legacy_helper_exports_match_new_workload_helpers(self): + sample = { + "spec": { + "template": { + "spec": { + "containers": [{"name": "main", "image": "nginx:1.27.2"}], + "initContainers": [{"name": "init", "image": "busybox:1.36"}], + } + } + } + } + + self.assertEqual(list(iter_containers(sample)), list(legacy_iter_containers(sample))) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_check_must_coverage.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_check_must_coverage.py new file mode 100644 index 00000000..609dae4f --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_check_must_coverage.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +import os +import tempfile +import textwrap +import unittest +from pathlib import Path + +from check_must_coverage import main, validate_must_coverage + + +def write_file(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content).lstrip("\n"), encoding="utf-8") + + +class CheckMustCoverageTests(unittest.TestCase): + def test_passes_when_must_mapping_is_complete(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill_file = root / "SKILL.md" + mapping_file = root / "references" / "must-rules-map.yaml" + rules_file = root / "references" / "rules-registry.yaml" + + write_file( + skill_file, + """ + ## MUST Rules (Condensed) + - Do not use `:latest`. + - `revisionHistoryLimit: 1` + ## Validation Commands + """, + ) + write_file( + mapping_file, + """ + version: 1 + must_rules: + - must: "Do not use `:latest`." + enforcement: + type: rule + target: R001 + - must: "`revisionHistoryLimit: 1`" + enforcement: + type: rule + target: R009 + """, + ) + write_file( + rules_file, + """ + version: 1 + rules: + - id: R001 + description: test + severity: error + - id: R009 + description: test + severity: error + """, + ) + + errors = validate_must_coverage(skill_file, mapping_file, rules_file) + self.assertEqual([], errors) + + def test_fails_when_must_mapping_missing_entry(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill_file = root / "SKILL.md" + mapping_file = root / "references" / "must-rules-map.yaml" + rules_file = root / "references" / "rules-registry.yaml" + + write_file( + skill_file, + """ + ## MUST Rules (Condensed) + - Do not use `:latest`. + - `revisionHistoryLimit: 1` + ## Validation Commands + """, + ) + write_file( + mapping_file, + """ + version: 1 + must_rules: + - must: "Do not use `:latest`." + enforcement: + type: rule + target: R001 + """, + ) + write_file( + rules_file, + """ + version: 1 + rules: + - id: R001 + description: test + severity: error + - id: R009 + description: test + severity: error + """, + ) + + errors = validate_must_coverage(skill_file, mapping_file, rules_file) + self.assertTrue(any("missing MUST mappings:" in item for item in errors)) + + def test_fails_when_mapping_targets_unknown_rule(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skill_file = root / "SKILL.md" + mapping_file = root / "references" / "must-rules-map.yaml" + rules_file = root / "references" / "rules-registry.yaml" + + write_file( + skill_file, + """ + ## MUST Rules (Condensed) + - Do not use `:latest`. + ## Validation Commands + """, + ) + write_file( + mapping_file, + """ + version: 1 + must_rules: + - must: "Do not use `:latest`." + enforcement: + type: rule + target: R999 + """, + ) + write_file( + rules_file, + """ + version: 1 + rules: + - id: R001 + description: test + severity: error + """, + ) + + errors = validate_must_coverage(skill_file, mapping_file, rules_file) + self.assertTrue(any("undefined rule id: R999" in item for item in errors)) + + def test_main_resolves_default_paths_relative_to_skill_file(self): + with tempfile.TemporaryDirectory() as temp_dir, tempfile.TemporaryDirectory() as cwd_dir: + root = Path(temp_dir) + skill_file = root / "SKILL.md" + mapping_file = root / "references" / "must-rules-map.yaml" + rules_file = root / "references" / "rules-registry.yaml" + + write_file( + skill_file, + """ + ## MUST Rules (Condensed) + - Do not use `:latest`. + ## Validation Commands + """, + ) + write_file( + mapping_file, + """ + version: 1 + must_rules: + - must: "Do not use `:latest`." + enforcement: + type: rule + target: R001 + """, + ) + write_file( + rules_file, + """ + version: 1 + rules: + - id: R001 + description: test + severity: error + """, + ) + + original_cwd = Path.cwd() + try: + os.chdir(cwd_dir) + exit_code = main(["--skill", str(skill_file)]) + finally: + os.chdir(original_cwd) + + self.assertEqual(0, exit_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_compose_to_template.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_compose_to_template.py new file mode 100644 index 00000000..66048950 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_compose_to_template.py @@ -0,0 +1,1071 @@ +#!/usr/bin/env python3 +import re +import tempfile +import textwrap +import unittest +from pathlib import Path +from unittest import mock +from subprocess import CompletedProcess +from typing import List, Optional + +import yaml + +from check_consistency_rule_registry import REGISTERED_RULES +from check_consistency_runner import run_checks +from compose_to_template import ( + MetadataOptions, + ServiceShape, + build_zh_description, + convert_compose_to_template, + infer_metadata, + parse_args, + resolve_image_reference, + resolve_kompose_shapes, +) + + +def write_file(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content).lstrip("\n"), encoding="utf-8") + + +def parse_yaml_documents(path: Path): + return list(yaml.safe_load_all(path.read_text(encoding="utf-8"))) + + +def render_registry(include_paths: Optional[List[str]] = None) -> str: + include_paths = include_paths or ["SKILL.md", "references/placeholder.md"] + lines = ["version: 1", "scope:", " include:"] + for path in include_paths: + lines.append(f" - {path}") + lines.append("rules:") + for rule_id in sorted(REGISTERED_RULES.keys()): + lines.append(f" - id: {rule_id}") + lines.append(" description: test") + lines.append(" severity: error") + return "\n".join(lines) + "\n" + + +class ComposeToTemplateTests(unittest.TestCase): + def _meta(self, app_name: str = "demo") -> MetadataOptions: + return MetadataOptions( + app_name=app_name, + title="Demo", + description="Demo app", + url="https://demo.example.com", + git_repo="https://github.com/example/demo", + author="Sealos", + categories=("tool",), + repo_raw_base="https://raw.githubusercontent.com/labring-actions/templates/kb-0.9", + ) + + def test_generates_template_and_passes_consistency_rules(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + output_dir = root / "template" + write_file( + compose, + """ + services: + app: + image: nginx:1.27.2 + ports: + - "8080:80" + environment: + - NODE_ENV=production + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=output_dir, + meta=self._meta("demo"), + ) + + self.assertTrue(index_path.exists()) + + docs = parse_yaml_documents(index_path) + kinds = [doc.get("kind") for doc in docs if isinstance(doc, dict)] + self.assertEqual(["Template", "Deployment", "Service", "Ingress", "App"], kinds) + template = next(doc for doc in docs if doc.get("kind") == "Template") + zh = template["spec"]["i18n"]["zh"] + self.assertNotIn("title", zh) + self.assertRegex(zh["description"], re.compile(r"[\u3400-\u4DBF\u4E00-\u9FFF]")) + self.assertEqual( + "https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template/demo/README.md", + template["spec"]["readme"], + ) + self.assertEqual( + "https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template/demo/README_zh.md", + zh["readme"], + ) + service = next(doc for doc in docs if doc.get("kind") == "Service") + self.assertEqual("tcp-80", service["spec"]["ports"][0]["name"]) + self.assertEqual("${{ defaults.app_name }}", service["metadata"]["name"]) + self.assertEqual("${{ defaults.app_name }}", service["spec"]["selector"]["app"]) + self.assertEqual("${{ defaults.app_name }}", service["metadata"]["labels"]["app"]) + self.assertEqual( + "${{ defaults.app_name }}", + service["metadata"]["labels"]["cloud.sealos.io/app-deploy-manager"], + ) + ingress = next(doc for doc in docs if doc.get("kind") == "Ingress") + backend_service_name = ingress["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]["name"] + self.assertEqual("${{ defaults.app_name }}", ingress["metadata"]["name"]) + self.assertEqual("${{ defaults.app_name }}", backend_service_name) + self.assertEqual( + "${{ defaults.app_name }}", + ingress["metadata"]["labels"]["cloud.sealos.io/app-deploy-manager"], + ) + workload = next(doc for doc in docs if doc.get("kind") == "Deployment") + self.assertEqual( + [{"name": "${{ defaults.app_name }}"}], + workload["spec"]["template"]["spec"]["imagePullSecrets"], + ) + self.assertEqual( + { + "kubernetes.io/ingress.class": "nginx", + "nginx.ingress.kubernetes.io/proxy-body-size": "32m", + "nginx.ingress.kubernetes.io/server-snippet": ( + "client_header_buffer_size 64k;\n" + "large_client_header_buffers 4 128k;" + ), + "nginx.ingress.kubernetes.io/ssl-redirect": "true", + "nginx.ingress.kubernetes.io/backend-protocol": "HTTP", + "nginx.ingress.kubernetes.io/client-body-buffer-size": "64k", + "nginx.ingress.kubernetes.io/proxy-buffer-size": "64k", + "nginx.ingress.kubernetes.io/proxy-send-timeout": "300", + "nginx.ingress.kubernetes.io/proxy-read-timeout": "300", + "nginx.ingress.kubernetes.io/configuration-snippet": ( + "if ($request_uri ~* \\.(js|css|gif|jpe?g|png)) {\n" + " expires 30d;\n" + " add_header Cache-Control \"public\";\n" + "}" + ), + }, + ingress["metadata"]["annotations"], + ) + app = next(doc for doc in docs if doc.get("kind") == "App") + self.assertEqual("normal", app["spec"]["displayType"]) + self.assertEqual("link", app["spec"]["type"]) + + skill_root = Path(__file__).resolve().parent.parent + checker_skill = root / "SKILL.md" + checker_refs = root / "references" + checker_registry = checker_refs / "rules-registry.yaml" + write_file(checker_skill, "# local checker scope\n") + write_file(checker_refs / "placeholder.md", "# refs\n") + checker_registry.write_text(render_registry(), encoding="utf-8") + violations = run_checks( + skill_path=checker_skill, + references_dir=checker_refs, + registry_path=checker_registry, + additional_include_paths=[str(index_path)], + ) + self.assertEqual([], violations) + + def test_service_ports_always_include_names_for_multi_port_services(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + ports: + - "9000:9000" + - "9443:9443" + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + service = next(doc for doc in docs if doc.get("kind") == "Service") + ports = service["spec"]["ports"] + self.assertEqual("tcp-9000", ports[0]["name"]) + self.assertEqual("tcp-9443", ports[1]["name"]) + + def test_drops_https_port_when_http_port_exists(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + ports: + - "80:80" + - "443:443" + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + workload = next(doc for doc in docs if doc.get("kind") in {"Deployment", "StatefulSet"}) + service = next(doc for doc in docs if doc.get("kind") == "Service") + + container_ports = [item["containerPort"] for item in workload["spec"]["template"]["spec"]["containers"][0]["ports"]] + service_ports = [item["port"] for item in service["spec"]["ports"]] + self.assertEqual([80], container_ports) + self.assertEqual([80], service_ports) + + def test_filters_tls_certificate_mounts_from_persistent_storage(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + volumes: + - certs:/etc/nginx/ssl + - data:/var/lib/demo + volumes: + certs: {} + data: {} + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + workload = next(doc for doc in docs if doc.get("kind") == "StatefulSet") + + mounts = workload["spec"]["template"]["spec"]["containers"][0]["volumeMounts"] + mount_paths = [item["mountPath"] for item in mounts] + self.assertEqual(["/var/lib/demo"], mount_paths) + + def test_template_defaults_keep_double_brace_placeholders(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + template = next(doc for doc in docs if doc.get("kind") == "Template") + defaults = template["spec"]["defaults"] + self.assertEqual("demo-${{ random(8) }}", defaults["app_host"]["value"]) + self.assertEqual("demo-${{ random(8) }}", defaults["app_name"]["value"]) + + def test_secondary_workload_name_keeps_double_brace_placeholders(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + web: + image: ghcr.io/example/demo:1.0.0 + worker: + image: ghcr.io/example/demo:1.0.0 + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + workloads = [doc for doc in docs if doc.get("kind") in {"Deployment", "StatefulSet"}] + names = [doc["metadata"]["name"] for doc in workloads] + self.assertIn("${{ defaults.app_name }}", names) + self.assertIn("${{ defaults.app_name }}-worker", names) + + def test_skips_traefik_gateway_when_business_service_exists(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + traefik: + image: traefik:v3.1.4 + ports: + - "80:80" + - "443:443" + command: + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + app: + image: ghcr.io/example/demo:1.0.0 + ports: + - "3000:3000" + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + workloads = [doc for doc in docs if doc.get("kind") in {"Deployment", "StatefulSet"}] + self.assertEqual(1, len(workloads)) + container_image = workloads[0]["spec"]["template"]["spec"]["containers"][0]["image"] + self.assertEqual("ghcr.io/example/demo:1.0.0", container_image) + + ingress = next(doc for doc in docs if doc.get("kind") == "Ingress") + backend_service = ingress["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]["name"] + self.assertEqual("${{ defaults.app_name }}", backend_service) + + def test_keeps_traefik_when_it_is_only_application_service(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + traefik: + image: traefik:v3.1.4 + ports: + - "80:80" + - "443:443" + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + workload = next(doc for doc in docs if doc.get("kind") in {"Deployment", "StatefulSet"}) + container_image = workload["spec"]["template"]["spec"]["containers"][0]["image"] + self.assertEqual("traefik:v3.1.4", container_image) + + def test_maps_compose_command_to_container_args(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + command: + - server + - --port + - "9000" + worker: + image: ghcr.io/example/demo:1.0.0 + command: worker --log-level info + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + workloads = [doc for doc in docs if doc.get("kind") in {"Deployment", "StatefulSet"}] + app_workload = next(doc for doc in workloads if doc["metadata"]["name"] == "${{ defaults.app_name }}") + worker_workload = next( + doc for doc in workloads if doc["metadata"]["name"] == "${{ defaults.app_name }}-worker" + ) + app_args = app_workload["spec"]["template"]["spec"]["containers"][0].get("args") + worker_args = worker_workload["spec"]["template"]["spec"]["containers"][0].get("args") + self.assertEqual(["server", "--port", "9000"], app_args) + self.assertEqual(["worker", "--log-level", "info"], worker_args) + + def test_generates_http_liveness_and_readiness_for_official_authentik_server(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + server: + image: ghcr.io/goauthentik/server:2025.12.3 + command: + - server + ports: + - "9000:9000" + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("authentik"), + ) + docs = parse_yaml_documents(index_path) + workload = next(doc for doc in docs if doc.get("kind") in {"Deployment", "StatefulSet"}) + container = workload["spec"]["template"]["spec"]["containers"][0] + + liveness = container.get("livenessProbe", {}) + readiness = container.get("readinessProbe", {}) + startup = container.get("startupProbe", {}) + self.assertEqual("/-/health/live/", liveness.get("httpGet", {}).get("path")) + self.assertEqual(9000, liveness.get("httpGet", {}).get("port")) + self.assertEqual("/-/health/ready/", readiness.get("httpGet", {}).get("path")) + self.assertEqual(9000, readiness.get("httpGet", {}).get("port")) + self.assertEqual("/-/health/ready/", startup.get("httpGet", {}).get("path")) + self.assertEqual(9000, startup.get("httpGet", {}).get("port")) + self.assertEqual(90, startup.get("failureThreshold")) + + def test_generates_exec_liveness_and_readiness_for_official_authentik_worker(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + worker: + image: ghcr.io/goauthentik/server:2025.12.3 + command: + - worker + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("authentik"), + ) + docs = parse_yaml_documents(index_path) + workload = next(doc for doc in docs if doc.get("kind") in {"Deployment", "StatefulSet"}) + container = workload["spec"]["template"]["spec"]["containers"][0] + + liveness_cmd = container.get("livenessProbe", {}).get("exec", {}).get("command", []) + readiness_cmd = container.get("readinessProbe", {}).get("exec", {}).get("command", []) + startup_cmd = container.get("startupProbe", {}).get("exec", {}).get("command", []) + self.assertIn("ak healthcheck", " ".join(str(item) for item in liveness_cmd)) + self.assertIn("ak healthcheck", " ".join(str(item) for item in readiness_cmd)) + self.assertIn("ak healthcheck", " ".join(str(item) for item in startup_cmd)) + + def test_maps_compose_healthcheck_to_liveness_and_readiness(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"] + interval: 20s + timeout: 3s + retries: 4 + start_period: 15s + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + workload = next(doc for doc in docs if doc.get("kind") in {"Deployment", "StatefulSet"}) + container = workload["spec"]["template"]["spec"]["containers"][0] + + liveness = container.get("livenessProbe", {}) + readiness = container.get("readinessProbe", {}) + startup = container.get("startupProbe", {}) + self.assertEqual("/healthz", liveness.get("httpGet", {}).get("path")) + self.assertEqual(8080, liveness.get("httpGet", {}).get("port")) + self.assertEqual(20, liveness.get("periodSeconds")) + self.assertEqual(3, liveness.get("timeoutSeconds")) + self.assertEqual(4, liveness.get("failureThreshold")) + self.assertEqual(15, liveness.get("initialDelaySeconds")) + self.assertEqual("/healthz", readiness.get("httpGet", {}).get("path")) + self.assertEqual(8080, readiness.get("httpGet", {}).get("port")) + self.assertEqual("/healthz", startup.get("httpGet", {}).get("path")) + self.assertEqual(8080, startup.get("httpGet", {}).get("port")) + self.assertEqual(1, startup.get("failureThreshold")) + + def test_skips_socket_mount_from_stateful_storage_conversion(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - data:/data + volumes: + data: {} + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + workload = next(doc for doc in docs if doc.get("kind") == "StatefulSet") + mounts = workload["spec"]["template"]["spec"]["containers"][0]["volumeMounts"] + mount_paths = [item["mountPath"] for item in mounts] + self.assertEqual(["/data"], mount_paths) + pvcs = workload["spec"]["volumeClaimTemplates"] + pvc_names = [item["metadata"]["name"] for item in pvcs] + self.assertEqual(["vn-data"], pvc_names) + + def test_rejects_latest_image_tag(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: nginx:latest + """, + ) + with self.assertRaises(ValueError): + convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + + def test_resolves_compose_image_default_expressions(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ${APP_IMAGE:-ghcr.io/example/demo}:${APP_TAG:-1.2.3} + """, + ) + with mock.patch.dict("os.environ", {}, clear=False): + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + workload = next(doc for doc in docs if doc.get("kind") in {"Deployment", "StatefulSet"}) + image = workload["spec"]["template"]["spec"]["containers"][0]["image"] + origin = workload["metadata"]["annotations"]["originImageName"] + self.assertEqual("ghcr.io/example/demo:1.2.3", image) + self.assertEqual("ghcr.io/example/demo:1.2.3", origin) + + def test_rejects_unresolved_compose_image_variable(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ${APP_IMAGE} + """, + ) + with mock.patch.dict("os.environ", {}, clear=False): + with self.assertRaises(ValueError): + convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + + def test_generates_postgres_resources_and_secret_db_env_mapping(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + environment: + DB_HOST: postgres + DB_PORT: "5432" + DB_USER: postgres + DB_PASSWORD: super-secret + DATABASE_URL: postgres://postgres:super-secret@postgres:5432/postgres + postgres: + image: postgres:16.4 + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + kinds = [doc.get("kind") for doc in docs if isinstance(doc, dict)] + + self.assertIn("ServiceAccount", kinds) + self.assertIn("Role", kinds) + self.assertIn("RoleBinding", kinds) + self.assertIn("Cluster", kinds) + + cluster = next(doc for doc in docs if doc.get("kind") == "Cluster") + self.assertEqual("${{ defaults.app_name }}-pg", cluster["metadata"]["name"]) + self.assertIn("kb.io/database", cluster["metadata"]["labels"]) + self.assertNotIn("finalizers", cluster["metadata"]) + self.assertNotIn("annotations", cluster["metadata"]) + affinity = cluster["spec"]["affinity"] + self.assertNotIn("nodeLabels", affinity) + self.assertNotIn("topologyKeys", affinity) + pg_comp = cluster["spec"]["componentSpecs"][0] + self.assertEqual("500m", pg_comp["resources"]["limits"]["cpu"]) + self.assertEqual("512Mi", pg_comp["resources"]["limits"]["memory"]) + self.assertEqual("50m", pg_comp["resources"]["requests"]["cpu"]) + self.assertEqual("51Mi", pg_comp["resources"]["requests"]["memory"]) + + deployment = next(doc for doc in docs if doc.get("kind") == "Deployment") + env = deployment["spec"]["template"]["spec"]["containers"][0]["env"] + host_item = next(item for item in env if item["name"] == "DB_HOST") + port_item = next(item for item in env if item["name"] == "DB_PORT") + user_item = next(item for item in env if item["name"] == "DB_USER") + password_item = next(item for item in env if item["name"] == "DB_PASSWORD") + endpoint_item = next(item for item in env if item["name"] == "DATABASE_URL") + + for item, key in ( + (host_item, "host"), + (port_item, "port"), + (user_item, "username"), + (password_item, "password"), + ): + secret_ref = item.get("valueFrom", {}).get("secretKeyRef", {}) + self.assertEqual("${{ defaults.app_name }}-pg-conn-credential", secret_ref.get("name")) + self.assertEqual(key, secret_ref.get("key")) + + self.assertEqual( + "postgres://$(SEALOS_DATABASE_POSTGRES_USERNAME):$(SEALOS_DATABASE_POSTGRES_PASSWORD)" + "@$(SEALOS_DATABASE_POSTGRES_HOST):$(SEALOS_DATABASE_POSTGRES_PORT)/postgres", + endpoint_item.get("value"), + ) + for helper_name, key in ( + ("SEALOS_DATABASE_POSTGRES_HOST", "host"), + ("SEALOS_DATABASE_POSTGRES_PORT", "port"), + ("SEALOS_DATABASE_POSTGRES_USERNAME", "username"), + ("SEALOS_DATABASE_POSTGRES_PASSWORD", "password"), + ): + helper_item = next(item for item in env if item["name"] == helper_name) + secret_ref = helper_item.get("valueFrom", {}).get("secretKeyRef", {}) + self.assertEqual("${{ defaults.app_name }}-pg-conn-credential", secret_ref.get("name")) + self.assertEqual(key, secret_ref.get("key")) + + def test_uses_statefulset_when_service_has_persistent_mount(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + volumes: + - data:/var/lib/demo + ports: + - "3000:3000" + volumes: + data: {} + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + workload = next(doc for doc in docs if doc.get("kind") in {"Deployment", "StatefulSet"}) + self.assertEqual("StatefulSet", workload["kind"]) + self.assertIn("volumeClaimTemplates", workload["spec"]) + request = workload["spec"]["volumeClaimTemplates"][0]["spec"]["resources"]["requests"]["storage"] + self.assertEqual("1Gi", request) + + def test_generates_redis_cluster_resources_and_secret_env_mapping(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + environment: + REDIS_HOST: redis + redis: + image: redis:7.2.7 + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + kinds = [doc.get("kind") for doc in docs if isinstance(doc, dict)] + self.assertIn("ServiceAccount", kinds) + self.assertIn("Role", kinds) + self.assertIn("RoleBinding", kinds) + self.assertIn("Deployment", kinds) + self.assertIn("Cluster", kinds) + + cluster = next(doc for doc in docs if doc.get("kind") == "Cluster") + redis_comp = next(item for item in cluster["spec"]["componentSpecs"] if item["name"] == "redis") + redis_data = redis_comp["volumeClaimTemplates"][0]["spec"]["resources"]["requests"]["storage"] + self.assertEqual("1Gi", redis_data) + self.assertEqual("500m", redis_comp["resources"]["limits"]["cpu"]) + self.assertEqual("512Mi", redis_comp["resources"]["limits"]["memory"]) + self.assertEqual("50m", redis_comp["resources"]["requests"]["cpu"]) + self.assertEqual("51Mi", redis_comp["resources"]["requests"]["memory"]) + sentinel_comp = next(item for item in cluster["spec"]["componentSpecs"] if item["name"] == "redis-sentinel") + self.assertEqual("500m", sentinel_comp["resources"]["limits"]["cpu"]) + self.assertEqual("512Mi", sentinel_comp["resources"]["limits"]["memory"]) + self.assertEqual("50m", sentinel_comp["resources"]["requests"]["cpu"]) + self.assertEqual("51Mi", sentinel_comp["resources"]["requests"]["memory"]) + + deployment = next(doc for doc in docs if doc.get("kind") == "Deployment") + env = deployment["spec"]["template"]["spec"]["containers"][0]["env"] + redis_host = next(item for item in env if item["name"] == "REDIS_HOST") + self.assertEqual( + "${{ defaults.app_name }}-redis-redis-redis.${{ SEALOS_NAMESPACE }}.svc.cluster.local", + redis_host.get("value"), + ) + + def test_generates_mysql_cluster_resources_and_secret_env_mapping(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + environment: + MYSQL_HOST: mysql + MYSQL_PORT: "3306" + mysql: + image: mysql:8.0.35 + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + cluster = next(doc for doc in docs if doc.get("kind") == "Cluster") + self.assertEqual("${{ defaults.app_name }}-mysql", cluster["metadata"]["name"]) + self.assertNotIn("finalizers", cluster["metadata"]) + self.assertNotIn("annotations", cluster["metadata"]) + self.assertEqual(["kubernetes.io/hostname"], cluster["spec"]["affinity"]["topologyKeys"]) + mysql_comp = cluster["spec"]["componentSpecs"][0] + self.assertEqual("500m", mysql_comp["resources"]["limits"]["cpu"]) + self.assertEqual("512Mi", mysql_comp["resources"]["limits"]["memory"]) + self.assertEqual("50m", mysql_comp["resources"]["requests"]["cpu"]) + self.assertEqual("51Mi", mysql_comp["resources"]["requests"]["memory"]) + + deployment = next(doc for doc in docs if doc.get("kind") == "Deployment") + env = deployment["spec"]["template"]["spec"]["containers"][0]["env"] + mysql_host = next(item for item in env if item["name"] == "MYSQL_HOST") + mysql_port = next(item for item in env if item["name"] == "MYSQL_PORT") + + host_ref = mysql_host.get("valueFrom", {}).get("secretKeyRef", {}) + port_ref = mysql_port.get("valueFrom", {}).get("secretKeyRef", {}) + self.assertEqual("${{ defaults.app_name }}-mysql-conn-credential", host_ref.get("name")) + self.assertEqual("host", host_ref.get("key")) + self.assertEqual("${{ defaults.app_name }}-mysql-conn-credential", port_ref.get("name")) + self.assertEqual("port", port_ref.get("key")) + + def test_generates_mongodb_cluster_resources_and_secret_env_mapping(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + environment: + MONGO_HOST: mongo + mongo: + image: mongo:8.0.4 + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + cluster = next(doc for doc in docs if doc.get("kind") == "Cluster") + self.assertEqual("${{ defaults.app_name }}-mongo", cluster["metadata"]["name"]) + self.assertNotIn("finalizers", cluster["metadata"]) + self.assertNotIn("annotations", cluster["metadata"]) + mongo_comp = cluster["spec"]["componentSpecs"][0] + self.assertEqual("mongodb", mongo_comp["componentDef"]) + self.assertEqual("8.0.4", mongo_comp["serviceVersion"]) + self.assertEqual("500m", mongo_comp["resources"]["limits"]["cpu"]) + self.assertEqual("512Mi", mongo_comp["resources"]["limits"]["memory"]) + self.assertEqual("50m", mongo_comp["resources"]["requests"]["cpu"]) + self.assertEqual("51Mi", mongo_comp["resources"]["requests"]["memory"]) + + deployment = next(doc for doc in docs if doc.get("kind") == "Deployment") + env = deployment["spec"]["template"]["spec"]["containers"][0]["env"] + mongo_host = next(item for item in env if item["name"] == "MONGO_HOST") + host_ref = mongo_host.get("valueFrom", {}).get("secretKeyRef", {}) + self.assertEqual("${{ defaults.app_name }}-mongo-mongodb-account-root", host_ref.get("name")) + self.assertEqual("host", host_ref.get("key")) + + def test_composes_mongodb_url_with_service_host_and_credential_secret(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + environment: + MONGODB_URI: mongodb://root:password@mongo:27017/demo?authSource=admin + mongo: + image: mongo:8.0.4 + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + deployment = next(doc for doc in docs if doc.get("kind") == "Deployment") + env = deployment["spec"]["template"]["spec"]["containers"][0]["env"] + + host = next(item for item in env if item["name"] == "SEALOS_MONGODB_MONGODB_HOST") + port = next(item for item in env if item["name"] == "SEALOS_MONGODB_MONGODB_PORT") + username = next(item for item in env if item["name"] == "SEALOS_MONGODB_MONGODB_USERNAME") + password = next(item for item in env if item["name"] == "SEALOS_MONGODB_MONGODB_PASSWORD") + uri = next(item for item in env if item["name"] == "MONGODB_URI") + + self.assertEqual("${{ defaults.app_name }}-mongo-mongodb.${{ SEALOS_NAMESPACE }}.svc.cluster.local", host["value"]) + self.assertEqual("27017", port["value"]) + self.assertEqual("${{ defaults.app_name }}-mongo-mongodb-account-root", username["valueFrom"]["secretKeyRef"]["name"]) + self.assertEqual("username", username["valueFrom"]["secretKeyRef"]["key"]) + self.assertEqual("${{ defaults.app_name }}-mongo-mongodb-account-root", password["valueFrom"]["secretKeyRef"]["name"]) + self.assertEqual("password", password["valueFrom"]["secretKeyRef"]["key"]) + self.assertEqual( + "mongodb://$(SEALOS_MONGODB_MONGODB_USERNAME):$(SEALOS_MONGODB_MONGODB_PASSWORD)@$(SEALOS_MONGODB_MONGODB_HOST):$(SEALOS_MONGODB_MONGODB_PORT)/demo?authSource=admin", + uri["value"], + ) + + def test_generates_kafka_cluster_resources_and_secret_env_mapping(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + environment: + KAFKA_HOST: kafka + kafka: + image: bitnami/kafka:3.3.2 + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + ) + docs = parse_yaml_documents(index_path) + cluster = next(doc for doc in docs if doc.get("kind") == "Cluster") + self.assertEqual("${{ defaults.app_name }}-broker", cluster["metadata"]["name"]) + broker_comp = next(item for item in cluster["spec"]["componentSpecs"] if item["name"] == "broker") + controller_comp = next(item for item in cluster["spec"]["componentSpecs"] if item["name"] == "controller") + metrics_comp = next(item for item in cluster["spec"]["componentSpecs"] if item["name"] == "metrics-exp") + for comp in (broker_comp, controller_comp, metrics_comp): + self.assertEqual("500m", comp["resources"]["limits"]["cpu"]) + self.assertEqual("512Mi", comp["resources"]["limits"]["memory"]) + self.assertEqual("50m", comp["resources"]["requests"]["cpu"]) + self.assertEqual("51Mi", comp["resources"]["requests"]["memory"]) + + deployment = next(doc for doc in docs if doc.get("kind") == "Deployment") + env = deployment["spec"]["template"]["spec"]["containers"][0]["env"] + kafka_host = next(item for item in env if item["name"] == "KAFKA_HOST") + host_ref = kafka_host.get("valueFrom", {}).get("secretKeyRef", {}) + self.assertEqual("${{ defaults.app_name }}-broker-account-admin", host_ref.get("name")) + self.assertEqual("host", host_ref.get("key")) + + def test_applies_kompose_shape_when_compose_ports_missing(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + compose = root / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + """, + ) + index_path, _ = convert_compose_to_template( + compose_path=compose, + output_root=root / "template", + meta=self._meta("demo"), + kompose_shapes={"app": ServiceShape(ports=(8080,), mount_paths=())}, + ) + docs = parse_yaml_documents(index_path) + service = next(doc for doc in docs if doc.get("kind") == "Service") + self.assertEqual(8080, service["spec"]["ports"][0]["port"]) + + def test_resolve_kompose_shapes_always_requires_binary(self): + with tempfile.TemporaryDirectory() as temp_dir: + compose = Path(temp_dir) / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + """, + ) + with mock.patch("compose_to_template.shutil.which", return_value=None): + with self.assertRaises(ValueError): + resolve_kompose_shapes(compose, "always") + + def test_resolve_kompose_shapes_auto_falls_back_when_binary_missing(self): + with tempfile.TemporaryDirectory() as temp_dir: + compose = Path(temp_dir) / "docker-compose.yml" + write_file( + compose, + """ + services: + app: + image: ghcr.io/example/demo:1.0.0 + """, + ) + with mock.patch("compose_to_template.shutil.which", return_value=None): + self.assertIsNone(resolve_kompose_shapes(compose, "auto")) + + def test_parse_args_defaults_to_always_kompose_mode(self): + args = parse_args(["--compose", "docker-compose.yml"]) + self.assertEqual("always", args.kompose_mode) + + def test_infer_metadata_normalizes_categories_to_allowlist(self): + args = parse_args( + [ + "--compose", + "docker-compose.yml", + "--category", + "security", + "--category", + "devops", + "--category", + "tool", + ] + ) + compose_data = {"services": {"app": {"image": "ghcr.io/example/demo:1.0.0"}}} + meta = infer_metadata(args, compose_data, Path("docker-compose.yml")) + self.assertEqual(("backend", "dev-ops", "tool"), meta.categories) + + def test_infer_metadata_falls_back_to_tool_for_unknown_categories(self): + args = parse_args( + [ + "--compose", + "docker-compose.yml", + "--category", + "security-policy", + ] + ) + compose_data = {"services": {"app": {"image": "ghcr.io/example/demo:1.0.0"}}} + meta = infer_metadata(args, compose_data, Path("docker-compose.yml")) + self.assertEqual(("tool",), meta.categories) + + def test_build_zh_description_rewrites_identity_platform_description(self): + zh_description = build_zh_description( + "ZITADEL", + "Open-source identity and access management platform for authentication and authorization.", + ) + self.assertEqual("开源身份与访问管理平台,提供认证与授权能力。", zh_description) + + def test_build_zh_description_keeps_existing_chinese_text(self): + zh_description = build_zh_description( + "Demo", + "开源身份与访问管理平台,提供认证与授权能力。", + ) + self.assertEqual("开源身份与访问管理平台,提供认证与授权能力。", zh_description) + + def test_resolve_image_reference_promotes_floating_tag_to_precise_version(self): + image = "ghcr.io/example/demo:v2" + + def fake_run(command, capture_output=True, text=True): # noqa: ANN001 + if command[-2:] == ["digest", "ghcr.io/example/demo:v2"]: + return CompletedProcess(command, 0, stdout="sha256:abc\n", stderr="") + if command[-2:] == ["ls", "ghcr.io/example/demo"]: + return CompletedProcess(command, 0, stdout="v2\nv2.2.0\nv2.1.9\n", stderr="") + if command[-2:] == ["digest", "ghcr.io/example/demo:v2.2.0"]: + return CompletedProcess(command, 0, stdout="sha256:abc\n", stderr="") + if command[-2:] == ["digest", "ghcr.io/example/demo:v2.1.9"]: + return CompletedProcess(command, 0, stdout="sha256:def\n", stderr="") + return CompletedProcess(command, 1, stdout="", stderr="unexpected command") + + with mock.patch("compose_to_template.shutil.which", return_value="/usr/local/bin/crane"): + with mock.patch("compose_to_template.subprocess.run", side_effect=fake_run): + resolved = resolve_image_reference(image) + + self.assertEqual("ghcr.io/example/demo:v2.2.0", resolved) + + def test_resolve_image_reference_falls_back_to_digest_when_no_precise_tag_matches(self): + image = "ghcr.io/example/demo:v2" + + def fake_run(command, capture_output=True, text=True): # noqa: ANN001 + if command[-2:] == ["digest", "ghcr.io/example/demo:v2"]: + return CompletedProcess(command, 0, stdout="sha256:abc\n", stderr="") + if command[-2:] == ["ls", "ghcr.io/example/demo"]: + return CompletedProcess(command, 0, stdout="v2\nv2.2.0\n", stderr="") + if command[-2:] == ["digest", "ghcr.io/example/demo:v2.2.0"]: + return CompletedProcess(command, 0, stdout="sha256:def\n", stderr="") + return CompletedProcess(command, 1, stdout="", stderr="unexpected command") + + with mock.patch("compose_to_template.shutil.which", return_value="/usr/local/bin/crane"): + with mock.patch("compose_to_template.subprocess.run", side_effect=fake_run): + resolved = resolve_image_reference(image) + + self.assertEqual("ghcr.io/example/demo@sha256:abc", resolved) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_quality_gate.py b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_quality_gate.py new file mode 100644 index 00000000..4174a596 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/docker-to-sealos/scripts/test_quality_gate.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import quality_gate + + +class QualityGateArtifactTests(unittest.TestCase): + def test_resolve_artifact_targets_prefers_env_override(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + with mock.patch.dict(os.environ, {"DOCKER_TO_SEALOS_ARTIFACTS": "template/demo/index.yaml"}, clear=False): + self.assertEqual("template/demo/index.yaml", quality_gate._resolve_artifact_targets(root)) + + def test_resolve_artifact_targets_returns_empty_when_template_missing(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual("", quality_gate._resolve_artifact_targets(root)) + + def test_resolve_artifact_targets_collects_index_yaml_files(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + app_a = root / "template" / "a" / "index.yaml" + app_b = root / "template" / "b" / "index.yaml" + app_a.parent.mkdir(parents=True, exist_ok=True) + app_b.parent.mkdir(parents=True, exist_ok=True) + app_a.write_text("kind: Template\n", encoding="utf-8") + app_b.write_text("kind: Template\n", encoding="utf-8") + + with mock.patch.dict(os.environ, {}, clear=True): + targets = quality_gate._resolve_artifact_targets(root) + self.assertEqual(f"{app_a},{app_b}", targets) + + def test_validate_artifact_targets_fails_without_artifacts_by_default(self): + ok, message = quality_gate.validate_artifact_targets("", allow_empty=False) + self.assertFalse(ok) + self.assertIn("no template artifacts found", message) + + def test_validate_artifact_targets_allows_empty_when_explicitly_enabled(self): + ok, message = quality_gate.validate_artifact_targets("", allow_empty=True) + self.assertTrue(ok) + self.assertTrue(message.startswith("[WARN]")) + + def test_build_commands_includes_artifacts_argument(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + commands = quality_gate.build_commands(root, "template/demo/index.yaml") + command_args = [list(item[1]) for item in commands] + self.assertTrue( + any("--artifacts" in args and "template/demo/index.yaml" in args for args in command_args) + ) + + def test_build_commands_omits_artifacts_argument_when_empty(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + commands = quality_gate.build_commands(root, "") + command_args = [list(item[1]) for item in commands] + self.assertTrue(all("--artifacts" not in args for args in command_args)) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/SKILL.md b/plugins/labring/sealos-skills/skills/dockerfile-skill/SKILL.md new file mode 100644 index 00000000..308ff0cd --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/SKILL.md @@ -0,0 +1,279 @@ +--- +name: dockerfile-skill +description: Generate production-ready Dockerfile for any GitHub project. Supports monorepo, multi-stage builds, workspace detection, and iterative build-fix cycles. Use when user asks to create, generate, write, fix, or improve a Dockerfile, wants to containerize an application, mentions Docker build issues, needs a .dockerignore, or wants to package their app as a Docker image. Also triggers on "/dockerfile". +--- + +# Dockerfile Generator Skill + +## Overview + +This skill generates production-ready Dockerfiles through a 4-phase process: +1. **Deep Analysis** - Understand project structure, workspace, migrations, and build complexity +2. **Generate** - Create Dockerfile with migration handling and build optimization +3. **Build & Fix** - Validate through actual build, fix errors iteratively +4. **Runtime Validation** - Verify migrations ran, app works, database populated + +## Key Capabilities + +- **Workspace/Monorepo Support**: pnpm workspace, Turborepo, npm workspaces +- **Custom CLI Detection**: Auto-detect custom build CLIs (turbo, nx, lerna, rush, or project-specific) and use correct syntax +- **Git Hash Bypass**: Detect and handle projects requiring git commit hash (GITHUB_SHA) +- **Build-Time Env Vars**: Auto-detect and add placeholders for Next.js SSG +- **Error Pattern Database**: 40+ known error patterns with automatic fixes +- **Smart .dockerignore**: Avoid excluding workspace-required files and CLI config dependencies +- **Custom Entry Points**: Support for custom server launchers +- **Migration Detection**: Auto-detect ORM, migrations, handle standalone mode +- **Build Optimization**: Skip heavy CI tasks (lint/type-check) to prevent OOM +- **Runtime Validation**: Verify migrations ran, database populated, app working +- **Native Module Support**: Auto-detect Rust/NAPI-RS modules, multi-architecture builds +- **Static Asset Mapping**: Detect backend's expected static paths and map frontend outputs +- **External Services**: Auto-detect PostgreSQL, Redis, MinIO, ManticoreSearch dependencies +- **Zero Human Interaction**: Auto-generate all config files including secrets + +## Usage + +``` +/dockerfile # Analyze current directory +/dockerfile # Clone and analyze GitHub repo +/dockerfile # Analyze specific path +``` + +## Quick Start + +When invoked, ALWAYS follow this sequence: + +1. Read and execute [modules/analyze.md](modules/analyze.md) +2. Read and execute [modules/generate.md](modules/generate.md) +3. Read and execute [modules/build-fix.md](modules/build-fix.md) + +## Workflow + +### Phase 1: Deep Project Analysis + +Load and execute: [modules/analyze.md](modules/analyze.md) + +**Output**: Structured project metadata including: +- Language / Framework / Package manager +- Build commands / Run commands / Port +- External dependencies (DB/Redis/S3) +- System library requirements +- **Migration system detection** (ORM, migration count, execution method) +- **Build complexity analysis** (heavy operations, memory risk) +- Complexity level (L1/L2/L3) + +### Phase 2: Generate Dockerfile + +Load and execute: [modules/generate.md](modules/generate.md) + +**Input**: Analysis result from Phase 1 +**Output**: +- `Dockerfile` (with migration handling, build optimization) +- `.dockerignore` (workspace-aware) +- `docker-compose.yml` (if external services needed) +- `.env.docker.local` (auto-generated with test secrets) +- `docker-entrypoint.sh` (with migration execution) +- `DOCKER.md` (complete deployment guide) +- Environment variable documentation + +**Key Enhancements**: +- Auto-detect Next.js Standalone + ORM → separate deps installation +- Auto-detect heavy build operations → optimized build command +- Auto-generate all config files → zero user input required + +### Phase 3: Build Validation (Closed Loop) + +Load and execute: [modules/build-fix.md](modules/build-fix.md) + +**Process**: +1. Execute `docker buildx build --platform linux/amd64 --load` +2. If success → Proceed to Phase 4 +3. If failure → Parse error, match pattern, fix Dockerfile, retry +4. Max iterations based on complexity level + +### Phase 4: Runtime Validation + +**Critical Addition**: Don't declare success until runtime verification passes! + +**Validation Steps**: +1. **Container Startup**: `docker-compose up -d` and verify no crashes +2. **Database Migration**: + - Query database: `psql -c "\dt"` → verify tables exist + - Check migration count matches expected (e.g., 76/76) + - Verify no "relation does not exist" errors +3. **Application Health**: + - Test HTTP endpoint → 200/302/401 acceptable, 500 is failure + - Check logs for errors + - Verify health check endpoint +4. **Success Criteria**: Only declare success if ALL pass + +**Why This Matters**: +- Previous: Declared success after `docker build`, but app didn't work at runtime +- Now: Verify migrations ran, database populated, app actually functional +- Prevents silent migration failures (e.g., standalone mode missing ORM deps) + +## Supporting Resources + +- **Templates**: [templates/](templates/) - Base Dockerfile templates by tech stack +- **Error Patterns**: [knowledge/error-patterns.md](knowledge/error-patterns.md) - Known errors and fixes +- **System Dependencies**: [knowledge/system-deps.md](knowledge/system-deps.md) - NPM/Pip package → system library mapping +- **Best Practices**: [knowledge/best-practices.md](knowledge/best-practices.md) - Docker production best practices +- **Output Format**: [examples/output-format.md](examples/output-format.md) - Expected output structure + +## Complexity Levels + +| Level | Criteria | Max Build Iterations | +|-------|----------|---------------------| +| L1 | Single language, no build step, no external services, no migrations | 1 | +| L2 | Has build step, has external services (DB/Redis), simple migrations | 3 | +| L3 | Monorepo, multi-language, complex dependencies, build-time env vars, complex migrations (76+) | 5 | + +## Common Issues & Solutions + +### 1. Database migrations not running - MOST CRITICAL +**Symptom**: `relation "users" does not exist` at runtime +**Cause**: Migrations detected but never executed +**Prevention**: Analysis phase Step 12 detects migrations and configures execution +**Fix**: +- For Standalone + ORM: Install ORM deps separately +- Add runtime migration to entrypoint script +- Verify with `psql -c "\dt"` after container starts + +### 2. Out of Memory during build +**Symptom**: Exit code 137, `Killed`, heap out of memory +**Cause**: Build script includes lint/type-check for 39+ workspace packages +**Prevention**: Analysis phase Step 13 detects heavy operations +**Fix**: Skip CI tasks in Docker build, increase NODE_OPTIONS to 8192MB + +### 3. Workspace files not found +**Symptom**: `ENOENT: no such file or directory, open '/app/e2e/package.json'` +**Cause**: .dockerignore excludes workspace package.json files +**Fix**: Use `e2e/*` instead of `e2e`, then `!e2e/package.json` + +### 4. lockfile=false projects +**Symptom**: `Cannot generate lockfile because lockfile is set to false` +**Cause**: Project has `lockfile=false` in .npmrc +**Fix**: Use `pnpm install` instead of `pnpm install --frozen-lockfile` + +### 5. Build-time env vars missing +**Symptom**: `KEY_VAULTS_SECRET is not set` +**Cause**: Next.js SSG needs env vars at build time +**Fix**: Add ARG/ENV placeholders in build stage + +### 6. Node binary path +**Symptom**: `spawn /bin/node ENOENT` +**Cause**: Scripts hardcode `/bin/node` but `node:slim` has it at `/usr/local/bin/node` +**Fix**: Add `RUN ln -sf /usr/local/bin/node /bin/node` + +### 7. ORM not found in Standalone mode +**Symptom**: `Cannot find module 'drizzle-orm'` at runtime +**Cause**: Next.js standalone doesn't include all node_modules +**Prevention**: Analysis phase detects standalone + ORM combination +**Fix**: Install ORM separately in /deps and copy to final image + +### 8. Wrong build command for monorepo with custom CLI +**Symptom**: Build succeeds but output files missing (e.g., `assets-manifest.json` not found) +**Cause**: Using `yarn workspace @scope/pkg build` instead of detected custom CLI syntax +**Prevention**: Analysis phase Step 14 detects custom CLI +**Fix**: Use detected CLI syntax for all build commands + +### 9. Git hash required but .git not in Docker context +**Symptom**: `Failed to open git repo` or `nodegit` errors +**Cause**: Build tool requires git commit hash for versioning +**Prevention**: Analysis phase Step 14 detects git hash dependency +**Fix**: Set `ENV GITHUB_SHA=docker-build` to bypass git requirement + +### 10. CLI config files excluded by .dockerignore +**Symptom**: CLI initialization (e.g., `${CLI_NAME} init`) fails silently +**Cause**: `.prettierrc`, `.prettierignore`, or other config files excluded +**Prevention**: Analysis phase Step 14 detects config file dependencies +**Fix**: Remove config files from .dockerignore exclusions + +### 11. Static assets not found at runtime +**Symptom**: `ENOENT: no such file or directory, open '/app/static/assets-manifest.json'` +**Cause**: Frontend builds to different path than backend expects +**Prevention**: Analysis phase Step 14 detects static asset path mapping +**Fix**: Copy frontend outputs to backend's expected path in Dockerfile + +## Success Criteria + +A successful Dockerfile must: + +**Build Phase**: +1. Build without errors (`docker buildx build` exits 0) +2. Image size reasonable (< 2GB for most apps) +3. Follow production best practices (multi-stage, non-root, fixed versions) +4. Include all necessary supporting files (.dockerignore, docker-compose.yml, etc.) +5. Handle all workspace/monorepo requirements + +**Runtime Phase** - CRITICAL: +6. Container starts successfully (no crashes) +7. **Database migrations execute successfully** (if migrations detected) +8. **Database tables created** (verify with psql) +9. **Application responds with valid HTTP codes** (200/302/401, not 500) +10. **No runtime errors in logs** (no "relation does not exist", etc.) + +**DO NOT declare success if**: +- Build passes but runtime fails +- Migrations detected but tables missing +- App returns 500 errors +- Logs show database relation errors + +## Post-Build Validation COMPREHENSIVE + +After successful build, perform FULL validation: + +```bash +# 1. Start services +docker-compose up -d +sleep 30 # Wait for startup + +# 2. Check container status +docker-compose ps +# Expected: All containers UP and HEALTHY + +# 3. Verify database migrations +if [ migrations_detected ]; then + # List tables + docker-compose exec postgres psql -U -d -c "\dt" + # Expected: List of tables (users, sessions, etc.) + # If "Did not find any relations" → FAIL + + # Count migrations + MIGRATION_COUNT=$(docker-compose exec postgres psql -U -d -t -c "SELECT COUNT(*) FROM ;") + # Expected: Matches analysis count (e.g., 76) +fi + +# 4. Test application health +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3210) +# Expected: 200, 302, or 401 +# Unacceptable: 500, 502, 503 + +if [ "$HTTP_CODE" = "500" ]; then + echo "FAILURE: App returning 500 error" + docker-compose logs app + exit 1 +fi + +# 5. Check for errors in logs +docker-compose logs app | grep -i "error" | tail -20 +# Should NOT contain: +# - "relation does not exist" +# - "table not found" +# - "Cannot find module" + +# 6. Check image size +docker images + +# 7. Cleanup (if needed) +docker-compose down +``` + +**Validation Checklist**: +- [ ] Image built successfully +- [ ] Container started without crashes +- [ ] Database connection established +- [ ] **Migrations executed (if applicable)** +- [ ] **Database tables exist (if applicable)** +- [ ] HTTP endpoint returns valid status +- [ ] No errors in application logs +- [ ] Health check passes diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/examples/output-format.md b/plugins/labring/sealos-skills/skills/dockerfile-skill/examples/output-format.md new file mode 100644 index 00000000..bccf8c41 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/examples/output-format.md @@ -0,0 +1,217 @@ +# Output Format Examples + +## Successful Build Output + +``` +## Dockerfile Generation Complete + +### Project Analysis +- **Language**: Node.js (TypeScript) +- **Framework**: Next.js 14 +- **Package Manager**: pnpm +- **Complexity**: L2 (Medium) +- **Detected Port**: 3000 + +### Generated Files + +#### Dockerfile +```dockerfile +# syntax=docker/dockerfile:1.4 +FROM node:20.11.1-slim AS deps +... +``` + +#### .dockerignore +``` +.git +node_modules +.next +... +``` + +#### docker-compose.yml +```yaml +services: + app: + build: . + ports: + - "3000:3000" +... +``` + +### Build Results +Build successful! + +**Image**: `your-app:latest` +**Size**: ~245MB + +### Quick Start + +```bash +# Build +docker buildx build --platform linux/amd64 --load -t your-app:latest . + +# Run +docker run -d -p 3000:3000 your-app:latest + +# With docker-compose +docker-compose up -d +``` + +### Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `DATABASE_URL` | Yes | - | PostgreSQL connection string | +| `PORT` | No | 3000 | Server port | +``` + +--- + +## Build with Fixes Output + +``` +## Dockerfile Generation Complete + +### Project Analysis +- **Language**: Node.js (TypeScript) +- **Framework**: Next.js 14 +- **Package Manager**: npm +- **Complexity**: L3 (High) +- **Detected Port**: 3000 + +### Build Iterations + +#### Iteration 1: Failed +**Error**: `ENOENT: no such file or directory, open '/app/config/config.json'` +**Fix Applied**: Added `RUN mkdir -p /app/config && echo '{}' > /app/config/config.json` + +#### Iteration 2: Failed +**Error**: `Error: DATABASE_URL environment variable is required` +**Fix Applied**: Added `ARG DATABASE_URL=postgres://placeholder` and `ENV DATABASE_URL=$DATABASE_URL` + +#### Iteration 3: Success + +### Generated Files +[files output...] + +### Build Results +Build successful after 3 iterations + +### Fixes Applied +1. Created missing config directory and placeholder config.json +2. Added DATABASE_URL build-time placeholder for SSG compatibility + +### Notes +- The DATABASE_URL placeholder is used only during build +- Provide actual DATABASE_URL at runtime via environment variable +``` + +--- + +## Build Failed Output + +``` +## Dockerfile Generation - Manual Review Required + +### Project Analysis +- **Language**: Node.js +- **Framework**: Custom +- **Complexity**: L3 (High) + +### Build Iterations + +#### Iteration 1-5: Failed +**Final Error**: +``` +Error: Cannot find module '@company/internal-package' +``` + +### Attempted Fixes +1. Added node-gyp build dependencies +2. Increased NODE_OPTIONS memory +3. Added missing directories + +### Manual Steps Required + +The build failed due to a private npm package that requires authentication. + +**To fix**: +1. Add `.npmrc` with registry authentication: + ``` + //npm.pkg.github.com/:_authToken=${NPM_TOKEN} + @company:registry=https://npm.pkg.github.com + ``` + +2. Update Dockerfile to use build secrets: + ```dockerfile + RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci + ``` + +3. Build with: + ```bash + docker buildx build --platform linux/amd64 --load --secret id=npmrc,src=.npmrc -t app . + ``` + +### Partial Output +The best working version of Dockerfile has been saved. It may work with the above modifications. +``` + +--- + +## Analysis-Only Output (for debugging) + +``` +## Project Analysis Results + +### Detection Summary +| Property | Value | +|----------|-------| +| Language | Node.js | +| Runtime Version | 20.x | +| Framework | Next.js 14.1.0 | +| Package Manager | pnpm | +| Has TypeScript | Yes | +| Has Build Step | Yes | +| Output Mode | standalone | + +### Dependency Analysis + +#### NPM Packages with Native Dependencies +- `sharp` → requires `libvips-dev` +- `bcrypt` → requires `python3 make g++` + +#### External Services Detected +- PostgreSQL (from `@prisma/client`) +- Redis (from `ioredis`) + +### Environment Variables + +#### Build-time Required +| Variable | Source | Purpose | +|----------|--------|---------| +| `DATABASE_URL` | `schema.prisma` | Prisma client generation | +| `NEXT_PUBLIC_API_URL` | `next.config.js` | Public API endpoint | + +#### Runtime Required +| Variable | Source | Purpose | +|----------|--------|---------| +| `DATABASE_URL` | `lib/db.ts` | Database connection | +| `REDIS_URL` | `lib/cache.ts` | Cache connection | +| `JWT_SECRET` | `lib/auth.ts` | Authentication | + +### Complexity Assessment +**Level**: L3 (High) + +**Reasons**: +- SSG with database access during build +- Multiple native dependencies +- External service dependencies +- Environment variable requirements during build + +### Recommended Approach +1. Use multi-stage build with deps → build → runtime +2. Install `libvips-dev` and build tools in deps stage +3. Provide placeholder DATABASE_URL for build +4. Generate Prisma client before build step +``` diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/best-practices.md b/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/best-practices.md new file mode 100644 index 00000000..8a370d6a --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/best-practices.md @@ -0,0 +1,491 @@ +# Docker Best Practices + +## Base Image Selection + +### Version Pinning + +```dockerfile +# GOOD - Fixed patch version +FROM node:20.11.1-slim +FROM python:3.11.7-slim +FROM golang:1.21.6-alpine + +# BAD - Floating tags +FROM node:latest +FROM node:lts +FROM node:20 # Minor version can change +FROM python:3 # Major version only +``` + +### Image Variants + +| Variant | Size | Use Case | +|---------|------|----------| +| `alpine` | Smallest | Go static binaries, simple apps | +| `slim` | Small | Node.js, Python (recommended) | +| `bookworm/bullseye` | Medium | Need full toolchain | +| Default (no suffix) | Large | Avoid in production | + +### Recommended Base Images + +```dockerfile +# Node.js +FROM node:20.11.1-slim + +# Python +FROM python:3.11.7-slim + +# Go +FROM golang:1.21.6-alpine AS builder +FROM alpine:3.19 AS runtime +# Or for scratch: +FROM scratch + +# Java +FROM eclipse-temurin:21-jre-alpine + +# Ruby +FROM ruby:3.2-slim +``` + +--- + +## Multi-Stage Build Pattern + +### Standard 3-Stage Pattern + +```dockerfile +# Stage 1: Dependencies +FROM node:20-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# Stage 2: Build +FROM deps AS build +COPY . . +RUN npm run build + +# Stage 3: Runtime +FROM node:20-slim AS runtime +WORKDIR /app +COPY --from=build /app/dist ./dist +COPY --from=deps /app/node_modules ./node_modules +CMD ["node", "dist/index.js"] +``` + +### Why Multi-Stage? + +1. **Smaller images**: Build tools don't end up in production +2. **Better caching**: Dependencies layer changes less often +3. **Security**: Less attack surface + +--- + +## Layer Optimization + +### Order by Change Frequency + +```dockerfile +# GOOD - Least changing first +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# BAD - Source files invalidate cache for dependencies +COPY . . +RUN npm ci && npm run build +``` + +### Combine RUN Commands + +```dockerfile +# GOOD - Single layer +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# BAD - Multiple layers +RUN apt-get update +RUN apt-get install -y python3 +RUN apt-get install -y make +RUN apt-get install -y g++ +``` + +--- + +## Cache Mounts (BuildKit) + +```dockerfile +# syntax=docker/dockerfile:1.4 + +# npm +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +# yarn +RUN --mount=type=cache,target=/root/.yarn \ + yarn install --frozen-lockfile + +# pnpm +RUN --mount=type=cache,target=/pnpm/store \ + pnpm install --frozen-lockfile + +# pip +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements.txt + +# go +RUN --mount=type=cache,target=/go/pkg/mod \ + go build -o main . +``` + +--- + +## Security + +### Non-Root User + +```dockerfile +# Node.js (built-in user) +USER node + +# Python (create user) +RUN useradd -m -u 1000 appuser +USER appuser + +# Go/Alpine +RUN adduser -D -u 1000 appuser +USER appuser + +# Or use nobody +USER nobody +``` + +### File Permissions + +```dockerfile +# Set ownership before switching user +COPY --chown=node:node . . +USER node + +# Or change after copy +COPY . . +RUN chown -R node:node /app +USER node +``` + +### Don't Include Secrets + +```dockerfile +# BAD - Secret in image layer +ENV API_KEY=sk-xxxxx +COPY .env . + +# GOOD - Runtime injection +# Use docker run -e or docker-compose environment +``` + +--- + +## .dockerignore + +### Must Ignore + +``` +.git +.env +.env.* +node_modules +__pycache__ +*.pyc +.venv +vendor +dist +build +.next +coverage +*.log +``` + +### Should Ignore + +``` +.vscode +.idea +*.md +docs +tests +*.test.js +*.spec.ts +Makefile +docker-compose*.yml +Dockerfile* +``` + +--- + +## Health Check + +### Without curl (Recommended) + +```dockerfile +# Node.js +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD node -e "require('http').get('http://127.0.0.1:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" + +# Python +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')" +``` + +### With curl (If available) + +```dockerfile +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:3000/health || exit 1 +``` + +--- + +## Environment Variables + +### Build-time vs Runtime + +```dockerfile +# Build-time only (not in final image) +ARG NODE_VERSION=20 +FROM node:${NODE_VERSION}-slim + +# Runtime (persists in image) +ENV NODE_ENV=production +ENV PORT=3000 + +# Build-time passed to runtime +ARG VERSION +ENV APP_VERSION=$VERSION +``` + +### Default Values + +```dockerfile +# With default +ENV PORT=3000 +ENV NODE_ENV=production + +# Without default (must be provided at runtime) +# Just document in comments or separate file +``` + +--- + +## Signals and Graceful Shutdown + +### Exec Form (Recommended) + +```dockerfile +# GOOD - PID 1, receives signals +CMD ["node", "server.js"] +ENTRYPOINT ["python", "app.py"] + +# BAD - Shell wrapper, signals not forwarded +CMD node server.js +``` + +### With Init System + +```dockerfile +# For complex apps needing init +FROM node:20-slim +RUN apt-get update && apt-get install -y --no-install-recommends tini +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["node", "server.js"] +``` + +--- + +## Common Anti-Patterns + +### DON'T: Use ADD for URLs + +```dockerfile +# BAD +ADD https://example.com/file.tar.gz /app/ + +# GOOD +RUN curl -L https://example.com/file.tar.gz | tar xz -C /app/ +``` + +### DON'T: Run apt-get upgrade + +```dockerfile +# BAD - Unpredictable results +RUN apt-get update && apt-get upgrade -y + +# GOOD - Only install what you need +RUN apt-get update && apt-get install -y --no-install-recommends specific-package +``` + +### DON'T: Store data in container + +```dockerfile +# BAD - Data lost on container restart +RUN mkdir /data + +# GOOD - Use volumes +VOLUME ["/data"] +``` + +### DON'T: Hardcode paths that should be configurable + +```dockerfile +# BAD +WORKDIR /home/user/myapp + +# GOOD +WORKDIR /app +``` + +--- + +## Workspace / Monorepo Best Practices + +### pnpm Workspace Pattern + +```dockerfile +# Stage 1: Dependencies +FROM node:20-slim AS deps +WORKDIR /app + +# Enable pnpm +RUN corepack enable && corepack prepare pnpm@10.20.0 --activate + +# Copy workspace configuration files +COPY package.json pnpm-workspace.yaml .npmrc ./ + +# Copy ALL workspace package.json files for proper resolution +COPY packages ./packages +COPY patches ./patches +COPY e2e/package.json ./e2e/ +COPY apps/desktop/src/main/package.json ./apps/desktop/src/main/ + +# Install dependencies +# IMPORTANT: Check .npmrc for lockfile=false +# If lockfile=false, do NOT use --frozen-lockfile +RUN pnpm install --ignore-scripts +``` + +### Smart .dockerignore for Workspaces + +```dockerfile +# DON'T: Exclude entire directories +e2e # This excludes e2e/package.json too! +apps/desktop + +# DO: Exclude contents but keep package.json +e2e/* +!e2e/package.json + +apps/desktop/node_modules +apps/desktop/dist +apps/desktop/out +# This keeps apps/desktop/src/main/package.json +``` + +### Build-Time Environment Variables + +For Next.js and similar frameworks that need env vars during static generation: + +```dockerfile +# Build stage +FROM base AS build + +# Use ARG for build-time placeholders (more secure, not in final image) +ARG KEY_VAULTS_SECRET_PLACEHOLDER="build-placeholder-32chars" +ARG DATABASE_URL_PLACEHOLDER="postgres://placeholder:placeholder@localhost:5432/placeholder" + +# Set as ENV for the build process +ENV KEY_VAULTS_SECRET=${KEY_VAULTS_SECRET_PLACEHOLDER} +ENV DATABASE_URL=${DATABASE_URL_PLACEHOLDER} +ENV AUTH_SECRET=${KEY_VAULTS_SECRET_PLACEHOLDER} +ENV DATABASE_DRIVER="" + +# Build will now succeed even though these are placeholders +RUN npm run build +``` + +### Custom Server Entry Points + +For apps with custom server launchers: + +```dockerfile +# Runtime stage +FROM node:20-slim AS production + +# IMPORTANT: Create symlink for scripts that expect /bin/node +RUN ln -sf /usr/local/bin/node /bin/node + +# Copy custom entry point +COPY --from=build /app/scripts/serverLauncher/startServer.js ./startServer.js +COPY --from=build /app/scripts/_shared ./scripts/_shared + +# If using database migrations +COPY --from=build /app/scripts/migrateServerDB/docker.cjs ./docker.cjs +COPY --from=build /app/scripts/migrateServerDB/errorHint.js ./errorHint.js +COPY --from=build /app/packages/database/migrations ./migrations + +# Use custom entry point instead of server.js +CMD ["node", "startServer.js"] +``` + +### Files You Must NOT Exclude + +When creating .dockerignore for complex projects: + +``` +# These MUST be available during build: +# ✓ .npmrc (package manager config) +# ✓ pnpm-workspace.yaml +# ✓ patches/** (pnpm patched deps) +# ✓ All workspace package.json files +# ✓ Build scripts (prebuild.mts, etc.) +# ✓ Server launcher scripts +# ✓ Migration scripts and files +``` + +--- + +## Database/External Service Patterns + +### PostgreSQL with pgvector + +```yaml +# docker-compose.yml +services: + db: + image: pgvector/pgvector:pg16 # Not just postgres:16 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] +``` + +### Wait for Dependencies + +```dockerfile +# In app container, wait for DB to be ready +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD node -e "fetch('http://localhost:3000/api/health')" +``` + +```yaml +# docker-compose.yml +services: + app: + depends_on: + db: + condition: service_healthy +``` diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/error-patterns.md b/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/error-patterns.md new file mode 100644 index 00000000..64034c6d --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/error-patterns.md @@ -0,0 +1,940 @@ +# Error Pattern Knowledge Base + +## Pattern Format + +Each pattern includes: +- **regex**: Pattern to match in error output +- **category**: Error classification +- **fix**: Dockerfile modification to apply +- **confidence**: How reliably this fix works (high/medium/low) + +--- + +## Category: File System + +### ENOENT - File Not Found + +```yaml +pattern: "ENOENT.*no such file or directory.*['\"](.+?)['\"]" +category: filesystem +confidence: high +extract: path from capture group 1 +fix: | + # Before the failing RUN command, add: + RUN mkdir -p $(dirname {path}) && touch {path} + # Or for JSON config: + RUN mkdir -p $(dirname {path}) && echo '{}' > {path} +``` + +### ENOENT - Module Not Found + +```yaml +pattern: "Cannot find module ['\"](.+?)['\"]" +category: filesystem +confidence: medium +extract: module name +fix: | + # Check if it's a local file that wasn't copied + # Add to COPY if needed: + COPY {module_path} ./ +``` + +### Directory Not Found + +```yaml +pattern: "ENOTDIR|directory.*not found|No such file or directory: ['\"](.+?)['\"]" +category: filesystem +confidence: high +fix: | + RUN mkdir -p {directory} +``` + +--- + +## Category: Environment Variables + +### Required Env Not Set + +```yaml +pattern: "`(.+?)` is not set|(.+?) environment variable is required|process\\.env\\.(.+?) is (not defined|undefined)" +category: environment +confidence: high +extract: variable name +fix: | + # In build stage: + ARG {VAR_NAME}=placeholder_for_build + ENV {VAR_NAME}=${{VAR_NAME}} +``` + +### KeyError (Python) + +```yaml +pattern: "KeyError: ['\"](.+?)['\"]" +category: environment +confidence: medium +extract: key name +fix: | + # Check if it's an env var access + ENV {KEY}=placeholder +``` + +--- + +## Category: Memory + +### JavaScript Heap OOM + +```yaml +pattern: "JavaScript heap out of memory|FATAL ERROR.*Allocation failed" +category: memory +confidence: high +fix: | + ENV NODE_OPTIONS="--max-old-space-size=4096" + # If already set to 4096, increase to 8192 +``` + +### Process Killed (OOM Killer) + +```yaml +pattern: "Killed|Exit code: 137|signal: SIGKILL" +category: memory +confidence: high +fix: | + # For Node.js: + ENV NODE_OPTIONS="--max-old-space-size=8192" + # For general: suggest increasing Docker memory limit +``` + +--- + +## Category: Native Modules + +### node-gyp Build Failed + +```yaml +pattern: "gyp ERR!|node-gyp rebuild|Cannot find module.*node-gyp" +category: native_module +confidence: high +fix: | + # Add to deps/build stage: + RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* +``` + +### GCC/G++ Missing + +```yaml +pattern: "command 'gcc' failed|g\\+\\+: command not found|cc: not found" +category: native_module +confidence: high +fix: | + RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* +``` + +### Python distutils Missing + +```yaml +pattern: "No module named 'distutils'|ModuleNotFoundError.*distutils" +category: native_module +confidence: high +fix: | + RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + python3-distutils \ + && rm -rf /var/lib/apt/lists/* +``` + +--- + +## Category: Package-Specific + +### Sharp / libvips + +```yaml +pattern: "sharp|vips|Something went wrong installing the \"sharp\" module" +category: package_specific +confidence: high +fix: | + # In build stage: + RUN apt-get update && apt-get install -y --no-install-recommends \ + libvips-dev \ + && rm -rf /var/lib/apt/lists/* +``` + +### Canvas / Cairo + +```yaml +pattern: "canvas|cairo|pango|librsvg" +category: package_specific +confidence: high +fix: | + RUN apt-get update && apt-get install -y --no-install-recommends \ + libcairo2-dev \ + libpango1.0-dev \ + libjpeg-dev \ + libgif-dev \ + librsvg2-dev \ + && rm -rf /var/lib/apt/lists/* +``` + +### better-sqlite3 + +```yaml +pattern: "better-sqlite3|Could not locate the bindings file" +category: package_specific +confidence: high +fix: | + RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* +``` + +### bcrypt + +```yaml +pattern: "bcrypt.*error|node_modules/bcrypt" +category: package_specific +confidence: high +fix: | + RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* +``` + +--- + +## Category: Permission + +### EACCES Permission Denied + +```yaml +pattern: "EACCES.*permission denied|PermissionError.*Errno 13" +category: permission +confidence: medium +fix: | + # Before USER directive: + RUN chown -R node:node /app + # Or adjust the path in question +``` + +### npm/yarn EACCES + +```yaml +pattern: "npm ERR! EACCES|yarn.*EACCES" +category: permission +confidence: high +fix: | + # Ensure cache directory is writable: + RUN mkdir -p /home/node/.npm && chown -R node:node /home/node + USER node +``` + +--- + +## Category: Network + +### Network Timeout + +```yaml +pattern: "ETIMEDOUT|network timeout|request.*timed out" +category: network +confidence: medium +fix: | + # For npm: + RUN npm ci --network-timeout 600000 + # For yarn: + RUN yarn install --network-timeout 600000 +``` + +### Host Resolution Failed + +```yaml +pattern: "ENOTFOUND|getaddrinfo.*failed|Could not resolve host" +category: network +confidence: low +fix: | + # Usually a transient issue, retry may help + # Or add DNS configuration if persistent +``` + +--- + +## Category: Shell Syntax + +### Shell Syntax Error + +```yaml +pattern: "/bin/sh.*syntax error|unexpected (EOF|token)" +category: shell +confidence: high +fix: | + # Review RUN commands for: + # - Unescaped special characters ($, ", ', `) + # - Unclosed quotes + # - Complex command substitution + # Use heredoc for multi-line: + RUN < build` works +``` + +### Git Hash Required + +```yaml +pattern: "Failed to open git repo|nodegit.*ENOENT|Repository.*not found|git.*rev-parse.*failed" +category: custom_cli +confidence: high +phase: build +fix: | + # Build tools may require git hash for versioning + # In Docker, .git is not available + + # Set environment variable to bypass git requirement + ENV GITHUB_SHA=docker-build + + # Or use build arg for custom value: + ARG GIT_COMMIT=docker-build + ENV GITHUB_SHA=${GIT_COMMIT} +prevention: | + In analysis phase Step 14: + - grep for "GITHUB_SHA|Repository|rev-parse" in build tools + - Automatically add ENV GITHUB_SHA if detected +``` + +### CLI Init/Config File Missing + +```yaml +pattern: "init.*failed|prettier.*not found|Cannot read config file|eslint.*not found|tsconfig.*not found" +category: custom_cli +confidence: medium +phase: build +fix: | + # Custom CLI may depend on config files excluded by .dockerignore + + # Common required config files (check .dockerignore): + # - .prettierrc / .prettierignore (code formatting) + # - .eslintrc.* / oxlint.json (linting) + # - tsconfig.json (TypeScript) + # - .editorconfig (editor settings) + + # Fix: Comment out exclusions in .dockerignore + # Example: + # .prettierignore <- Comment this out + # .prettierrc <- Comment this out +prevention: | + In analysis phase Step 14: + - Detect which config files CLI depends on + - Ensure they are NOT in .dockerignore +``` + +### Static Assets Path Mismatch + +```yaml +pattern: "ENOENT.*static.*manifest|webAssets.*not found|Cannot find static directory" +category: custom_cli +confidence: high +phase: runtime +fix: | + # Backend code hardcodes expected static asset paths + # Must copy frontend outputs to correct locations + + # Example (paths detected from analysis): + COPY --from=builder /app/${FRONTEND_OUTPUT} ./${BACKEND_EXPECTS} + # e.g., COPY --from=builder /app/packages/web/dist ./static + + # Detection: Search backend code for static path references + # grep -rE "projectRoot.*static|assets-manifest|webAssets" packages/backend/ src/server/ +prevention: | + In analysis phase Step 14: + - Detect backend's expected static paths + - Map frontend output paths to expected locations + - Generate correct COPY commands in Dockerfile +``` + +--- + +## Category: Rust/Native Module Build + +### Rust Toolchain Missing + +```yaml +pattern: "cargo.*not found|rustc.*not found|error: no default toolchain configured" +category: native_module +confidence: high +phase: build +fix: | + # Install Rust toolchain in build stage + ENV RUSTUP_HOME=/usr/local/rustup \ + CARGO_HOME=/usr/local/cargo \ + PATH=/usr/local/cargo/bin:$PATH + + RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable +``` + +### Wrong Rust Target + +```yaml +pattern: "error: linking with.*failed|unknown target triple|can't find crate" +category: native_module +confidence: high +phase: build +fix: | + # Must use correct target for container architecture + ARG TARGETARCH + + RUN if [ "$TARGETARCH" = "arm64" ]; then \ + rustup target add aarch64-unknown-linux-gnu && \ + yarn workspace @pkg/native build --target aarch64-unknown-linux-gnu; \ + else \ + rustup target add x86_64-unknown-linux-gnu && \ + yarn workspace @pkg/native build --target x86_64-unknown-linux-gnu; \ + fi +``` + +### NAPI-RS Build Dependencies Missing + +```yaml +pattern: "napi.*build failed|tree-sitter.*error|clang.*not found" +category: native_module +confidence: high +phase: build +fix: | + # NAPI-RS and tree-sitter require clang + RUN apt-get update && apt-get install -y --no-install-recommends \ + clang \ + llvm \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + + # Set clang for compatibility + ENV CC="clang -D_BSD_SOURCE" \ + TARGET_CC="clang -D_BSD_SOURCE" +``` + +--- + +## Unknown Error Fallback + +If no pattern matches: + +1. Log the full error message +2. Check if error contains a file path → might be COPY issue or .dockerignore issue +3. Check if error contains package name → might be dependency issue +4. Check if error mentions env var → might need build-time placeholder +5. Check if error mentions workspace → might be missing workspace files +6. Return to user with error for manual review + +### Debugging Checklist + +When build fails with unknown error: + +1. **File not found**: Check .dockerignore, ensure file is not excluded +2. **Module not found**: Check if it's a workspace package that wasn't copied +3. **Env var not set**: Add ARG/ENV placeholder for build time +4. **Permission denied**: Check USER directive placement +5. **Command not found**: Check if binary exists in the image (node path, proxychains, etc.) diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/lessons-learned.md b/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/lessons-learned.md new file mode 100644 index 00000000..0b332c7c --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/lessons-learned.md @@ -0,0 +1,629 @@ +# Lessons Learned from Real Projects + +This document captures patterns and solutions from actual Dockerfile generation experiences to prevent repeated mistakes. + +--- + +## Case Study: LobeChat (L3 Complexity) + +**Project**: LobeChat - Next.js 16 + React 19 + pnpm workspace (39+ packages) +**Date**: 2026-02-05 +**Iterations**: 10+ (TOO MANY - should be reduced to 3-5 with improvements) +**Final Status**: Successful after implementing all fixes + +### Timeline of Issues + +#### Issue 1: TypeScript Project Reference +- **When**: Build iteration 1 +- **Error**: `File '/app/apps/desktop' not found` +- **Root Cause**: `.dockerignore` excluded `apps/desktop/tsconfig.json` +- **Fix**: Added `!apps/desktop/tsconfig.json` to .dockerignore +- **Prevention**: Generate.md now includes validation checklist for .dockerignore +- **Status**: Pattern added to skill + +#### Issue 2: Memory Exhaustion (OOM Killed) +- **When**: Build iteration 2 +- **Error**: Exit code 137, `JavaScript heap out of memory` +- **Root Cause**: `npm run build` included lint/type-check consuming 12GB+ memory +- **Fix**: Changed build command to skip CI tasks: + ```dockerfile + RUN npx tsx scripts/prebuild.mts && npx next build --webpack + ENV NODE_OPTIONS="--max-old-space-size=8192" + ``` +- **Prevention**: Analysis phase Step 13 now detects heavy operations +- **Status**: Pattern added to skill + +#### Issue 3: Sitemap Build Failure +- **When**: Build iteration 3 +- **Error**: `Cannot find module '/app/scripts/buildSitemapIndex/index.ts'` +- **Root Cause**: Sitemap scripts excluded by .dockerignore +- **Fix**: Removed sitemap generation (not essential for Docker) +- **Prevention**: Analysis phase now detects sitemap generation +- **Status**: Pattern added to skill + +#### Issue 4: bun Command Not Found +- **When**: Build iteration 4 +- **Error**: `sh: 1: bun: not found` during `build-migrate-db` +- **Root Cause**: Migration script used `bun run db:migrate` +- **Fix**: Removed build-time DB migration, moved to runtime +- **Prevention**: Analysis phase now detects runtime tool requirements +- **Status**: Pattern added to skill + +#### Issue 5: Database Migrations Not Running +- **When**: After deployment (runtime) +- **Error**: `relation "users" does not exist` +- **Root Cause**: Migrations never executed - Standalone mode doesn't include drizzle-orm +- **Investigation Steps**: + 1. Entrypoint attempted to run migrations with MIGRATION_DB=1 + 2. Standalone output missing drizzle-orm dependencies + 3. Migration script failed silently + 4. User reported app not working +- **Fix**: Manually executed all 76 SQL files into PostgreSQL +- **Prevention**: Analysis phase Step 12 now detects migration systems +- **Long-term Fix**: Generate Dockerfile with separate ORM deps: + ```dockerfile + # Build stage - install ORM separately + RUN mkdir -p /deps && cd /deps && pnpm add pg drizzle-orm + + # Production stage - copy ORM deps + COPY --from=build /deps/node_modules/drizzle-orm ./node_modules/drizzle-orm + COPY --from=build /deps/node_modules/pg ./node_modules/pg + ``` +- **Status**: Pattern added to skill + +#### Issue 6: Runtime Validation Missing +- **When**: After build completed +- **Problem**: Declared success after `docker build` passed, but app didn't work +- **Root Cause**: No runtime validation phase +- **Fix**: Added comprehensive runtime validation: + - Verify container starts + - Check database tables exist + - Test HTTP endpoints + - Scan logs for errors +- **Prevention**: Phase 4 Runtime Validation added +- **Status**: Pattern added to skill + +### User Feedback + +> "The entire process should not require human interaction" + +**Lesson**: Skill must be FULLY automated - auto-generate all config files including secrets. + +> "Image build should not depend on real environment variables" + +**Lesson**: Use placeholders at build time, inject real values at runtime. + +> "It took about 10+ iterations, too many" + +**Lesson**: Most issues should be detected in ANALYSIS phase, not discovered during build/runtime. + +> "The database migration issue should have been detected during code analysis phase, not after build completion when users tried to use it" + +**Lesson**: Migration detection is CRITICAL and must happen in analysis phase. + +--- + +## Patterns to Converge into Skill + +### Priority 1: Critical (Prevents Runtime Failures) + +#### 1.1 Migration System Detection (Analysis Phase) +```yaml +Implementation: modules/analyze.md - Step 12 +Detection: + - Check for migration directories (packages/*/migrations, prisma/migrations, etc.) + - Detect ORM type (Drizzle, Prisma, TypeORM) + - Count migration files + - Check if standalone mode + ORM (critical pattern) + - Verify migration execution method (build-time, runtime, none) +Warning Triggers: + - Migration files found BUT no execution method → CRITICAL + - Standalone mode + ORM without separate deps → CRITICAL + - Unknown ORM with migrations → WARNING +Benefit: Prevents "relation does not exist" failures at runtime +Status: Implemented in modules/analyze.md +``` + +#### 1.2 Runtime Validation Phase +```yaml +Implementation: modules/build-fix.md - Post-Build Validation +Validation Steps: + 1. Container startup check + 2. Database migration verification (psql -c "\dt") + 3. Migration count validation + 4. HTTP endpoint testing (200/302/401 OK, 500 FAIL) + 5. Log error scanning +Success Criteria: + - Only declare success if ALL validations pass + - Don't stop at docker build success +Benefit: Catches migration failures before user discovers them +Status: Implemented in modules/build-fix.md +``` + +#### 1.3 Standalone Mode + ORM Pattern +```yaml +Implementation: modules/generate.md - Migration Handling +Pattern Detection: + - Next.js output: 'standalone' + ORM detected +Solution: + - Install ORM deps separately in /deps + - Copy to final image alongside standalone output + - Include migration files + - Create proper entrypoint script +Example: LobeChat pattern from official Dockerfile +Benefit: Prevents silent migration failures in standalone mode +Status: Implemented in modules/generate.md +``` + +### Priority 2: High (Prevents Build Failures) + +#### 2.1 Build Script Complexity Analysis +```yaml +Implementation: modules/analyze.md - Step 13 +Detection: + - Parse package.json build script + - Identify heavy operations (lint, type-check, test, sitemap) + - Count workspace packages (memory multiplier) + - Calculate memory risk +Recommendation: + - Workspace 39+ packages + lint/type-check = HIGH RISK + - Suggest optimized build command (skip CI tasks) + - Set appropriate NODE_OPTIONS memory limit +Benefit: Prevents OOM failures (Exit 137) +Status: Implemented in modules/analyze.md +``` + +#### 2.2 Build Optimization in Generation +```yaml +Implementation: modules/generate.md - Build Optimization Handling +Application: + - Use optimized build command from analysis + - Add comments explaining why operations skipped + - Set memory limits based on complexity +Example: + # Skipping lint/type-check (run in CI, not Docker) + RUN npx tsx scripts/prebuild.mts && npx next build + ENV NODE_OPTIONS="--max-old-space-size=8192" +Benefit: Reduces build time and prevents OOM +Status: Implemented in modules/generate.md +``` + +### Priority 3: Medium (Improves User Experience) + +#### 3.1 Complete Automation +```yaml +Implementation: modules/generate.md - Output Files +Auto-Generate: + - .env.docker.local with test secrets (32+ char random) + - docker-entrypoint.sh with migration logic + - DOCKER.md with deployment guide + - All supporting documentation +Zero User Input: + - User should NEVER need to create files manually + - User should NEVER need to generate secrets + - docker-compose up -d should work immediately +Benefit: Achieves "zero human interaction" requirement +Status: Should be implemented in next iteration +``` + +#### 3.2 Environment Variable Patterns +```yaml +Implementation: modules/analyze.md + modules/generate.md +Principle: + - Build-time: Use placeholders that pass validation + - Runtime: Inject real values via docker run -e or compose +Detection: + - Scan for required env vars + - Check minimum length requirements + - Generate valid placeholders automatically +Example: + ARG KEY_VAULTS_SECRET="build-placeholder-32chars-long-xxxxx" + # Real value at runtime: docker run -e KEY_VAULTS_SECRET="real-key" +Benefit: Build never depends on real secrets +Status: Already implemented +``` + +### Priority 4: Low (Project-Specific Optimizations) + +These are NOT converged into skill as they're too specific: + +#### 4.1 Image Size Optimization +- Using FROM scratch (distroless) +- Multi-architecture builds +- Compression techniques +- **Reason**: Too project-specific, official images vary widely + +#### 4.2 Regional Optimizations +- China mirror support (USE_CN_MIRROR) +- Regional CDN configuration +- **Reason**: Regional requirements, not universal + +#### 4.3 Proxychains/VPN Support +- Proxychains4 installation +- Proxy configuration +- **Reason**: Specific use case, not common enough + +--- + +## Error Pattern Enhancements + +### New Patterns Added + +#### Database Migration Errors (Runtime) +```yaml +Pattern: "relation \"(.+?)\" does not exist" +Category: migration_failed +Phase: runtime +Fix: Check migration system, install ORM deps separately if standalone +Prevention: Detect in analysis phase Step 12 +Added to: knowledge/error-patterns.md +``` + +#### ORM Module Not Found (Runtime) +```yaml +Pattern: "Cannot find module 'drizzle-orm'" +Category: migration_deps_missing +Phase: runtime +Fix: Install ORM separately, copy to final image +Prevention: Detect standalone + ORM in analysis phase +Added to: knowledge/error-patterns.md +``` + +#### Build Memory Issues +```yaml +Pattern: "Exit code: 137|Killed|heap out of memory" +Category: memory +Phase: build +Fix: Skip heavy operations, increase memory limit +Prevention: Detect in analysis phase Step 13 +Added to: knowledge/error-patterns.md +``` + +--- + +## Metrics & Success Criteria + +### Before Improvements +- **LobeChat Build**: 10+ iterations +- **Success Declaration**: After docker build passes +- **User Discovered Issues**: Database not working +- **Human Interaction**: Required for .env.docker.local + +### After Improvements (Target) +- **Iterations**: 3-5 maximum (even for L3 complexity) +- **Success Declaration**: After runtime validation passes +- **User Discovered Issues**: None (caught in validation) +- **Human Interaction**: Zero (fully automated) + +### Validation Metrics +```yaml +Detection Rate (Analysis Phase): + - Migration systems: 100% (was 0%) + - Heavy build operations: 100% (was 0%) + - ORM dependencies: 100% (was 0%) + +Prevention Rate (Generation Phase): + - OOM failures: 90%+ (was ~30%) + - Migration failures: 95%+ (was 0%) + - Runtime errors: 90%+ (was ~50%) + +Success Rate (Runtime Validation): + - Database tables created: Required check + - Application responding: Required check + - No silent failures: Verified before success declaration +``` + +--- + +## Implementation Status + +### Completed +1. Analysis Phase Step 12: Migration Detection +2. Analysis Phase Step 13: Build Complexity Analysis +3. Generate Phase: Migration Handling (Standalone + ORM pattern) +4. Generate Phase: Build Optimization +5. Build-Fix Phase: Runtime Validation +6. Error Patterns: Migration-related errors +7. Error Patterns: Build memory issues +8. SKILL.md: Updated workflow and capabilities + +### In Progress +1. Complete test coverage for new detection modules +2. Documentation examples for each pattern + +### Future Enhancements +1. Auto-generate .env.docker.local with proper random secrets +2. Auto-generate DOCKER.md with project-specific content +3. Enhanced validation reporting (structured JSON output) +4. Support for more ORMs (currently Drizzle/Prisma/TypeORM) + +--- + +## Summary + +### Key Learnings + +1. **Detect Early**: Most issues should be found in ANALYSIS phase, not BUILD or RUNTIME +2. **Validate Completely**: Don't declare success until runtime validation passes +3. **Automate Everything**: Zero human interaction is the goal +4. **Migration Critical**: Database migrations are #1 cause of runtime failures +5. **Build Optimization**: Heavy CI tasks cause OOM in Docker builds +6. **Custom CLI Detection**: Monorepos often use custom CLIs - using wrong command = silent failures + +### Impact + +By converging these patterns into the skill: +- **Reduced Iterations**: From 10+ to 3-5 for similar complexity +- **Earlier Detection**: Issues found in analysis, not runtime +- **Better Validation**: No silent failures +- **User Experience**: Zero manual steps required + +### Next Project Benefits + +When the skill encounters a similar project (L3 complexity with migrations): +1. Analysis phase detects migrations → warns about standalone + ORM +2. Generation phase uses separate deps pattern automatically +3. Runtime validation catches any migration failures +4. User gets working app on first try (or max 3-5 iterations) + +**This is convergence: Learning from experience to prevent repetition.** + +--- + +## Case Study: AFFiNE (L3 Complexity - Monorepo with Custom CLI) + +**Project**: AFFiNE - Knowledge base with frontend (Next.js) + backend (NestJS) + Rust native modules +**Date**: 2026-02-06 +**Iterations**: 5+ (reduced from initial failures) +**Final Status**: Successful after implementing all fixes + +### Project Characteristics + +- **Monorepo**: pnpm workspace with 50+ packages +- **Frontend**: React + Next.js (web, mobile, admin apps) +- **Backend**: NestJS server with Prisma ORM +- **Native Module**: Rust/NAPI-RS for performance-critical operations +- **Custom CLI**: `yarn affine` - custom build tool, NOT standard workspace commands +- **External Services**: PostgreSQL (pgvector), Redis, ManticoreSearch + +### Timeline of Issues + +#### Issue 1: Wrong Build Command (CRITICAL) + +- **When**: Initial Dockerfile generation +- **Error**: `ENOENT: no such file or directory, open '/app/static/assets-manifest.json'` +- **Root Cause**: Used `yarn workspace @affine/web build` instead of `yarn affine build -p @affine/web` +- **Investigation**: + 1. Official AFFiNE CI/CD uses `yarn affine build -p ` + 2. Custom CLI defined in `tools/cli/bin/cli.js` + 3. Individual packages' build scripts invoke the CLI internally +- **Fix**: Changed all build commands to use affine CLI syntax: + ```dockerfile + RUN yarn affine build -p @affine/web && \ + yarn affine build -p @affine/mobile && \ + yarn affine build -p @affine/admin + RUN yarn affine build -p @affine/server + ``` +- **Prevention**: Added Step 14 (Custom CLI Detection) to analyze.md +- **Status**: Pattern added to skill + +#### Issue 2: Git Hash Dependency + +- **When**: After fixing build command +- **Error**: `Failed to open git repo: could not find repository at '/app/'` +- **Root Cause**: Build tool uses `nodegit` to get commit hash for versioning, but `.git` not in Docker context +- **Detection**: Found in `tools/cli/src/webpack/html-plugin.ts`: + ```typescript + const gitShortHash = once(() => { + const { GITHUB_SHA } = process.env; + if (GITHUB_SHA) { + return GITHUB_SHA.substring(0, 9); + } + const repo = new Repository(ProjectRoot.value); // Fails without .git + }); + ``` +- **Fix**: Set environment variable to bypass git requirement: + ```dockerfile + ENV GITHUB_SHA=docker-build + ``` +- **Prevention**: Added git hash detection to Step 14 +- **Status**: Pattern added to skill + +#### Issue 3: Configuration Files in .dockerignore + +- **When**: During dependency installation +- **Error**: `affine init` failed silently, causing build issues +- **Root Cause**: `.prettierrc` and `.prettierignore` were in `.dockerignore` but needed by CLI +- **Investigation**: The `affine init` script (run in postinstall) requires these files for code generation +- **Fix**: Removed these files from .dockerignore: + ```dockerfile + # Keep prettier config (needed for affine init) + # .prettierignore + # .prettierrc + ``` +- **Prevention**: Added config file dependency detection to Step 14 +- **Status**: Pattern added to skill + +#### Issue 4: Static Assets Path Mapping + +- **When**: After frontend build succeeded but server failed at runtime +- **Error**: `ENOENT: no such file or directory, open '/app/static/assets-manifest.json'` +- **Root Cause**: Backend expects frontend builds at `/app/static/` but they were built to different paths +- **Investigation**: Found in backend code: + ```typescript + this.webAssets = this.readHtmlAssets(join(env.projectRoot, 'static')); + ``` +- **Fix**: Copy frontend outputs to expected locations: + ```dockerfile + COPY --from=app-builder /app/packages/frontend/apps/web/dist ./static + COPY --from=app-builder /app/packages/frontend/apps/mobile/dist ./static/mobile + COPY --from=app-builder /app/packages/frontend/admin/dist ./static/admin + ``` +- **Prevention**: Added static assets path detection to Step 14 +- **Status**: Pattern added to skill + +#### Issue 5: Rust Native Module Multi-Architecture + +- **When**: Building for different CPU architectures +- **Challenge**: Must build for both x86_64 and arm64 +- **Solution**: Auto-detect architecture and set appropriate Rust target: + ```dockerfile + ARG TARGETARCH + RUN if [ "$TARGETARCH" = "arm64" ]; then \ + rustup target add aarch64-unknown-linux-gnu; \ + yarn workspace @affine/server-native build --target aarch64-unknown-linux-gnu; \ + else \ + rustup target add x86_64-unknown-linux-gnu; \ + yarn workspace @affine/server-native build --target x86_64-unknown-linux-gnu; \ + fi + ``` +- **Prevention**: Added Step 15 (Native Module Detection) to analyze.md +- **Status**: Pattern added to skill + +### Key Differences from Official Approach + +| Aspect | Official AFFiNE | Self-Hosted Docker | +|--------|-----------------|-------------------| +| Build Location | GitHub Actions CI | Inside Docker container | +| Artifacts | Pre-built, uploaded | Built from source | +| Native Module | Pre-compiled binaries | Compiled during build | +| Dependencies | Minimal runtime | Full build toolchain | +| Image Size | ~500MB | ~2GB (includes build deps) | + +### Generalized Lessons (Applicable to ALL Monorepo Projects) + +1. **Detect Build System First**: Never assume standard `yarn workspace build` works. Always detect what CLI/build tool the project uses. +2. **Git Hash Bypass**: Many build tools require git hash. Set the appropriate env var (commonly `GITHUB_SHA`, `GIT_COMMIT`, etc.) in Docker builds. +3. **Config File Dependencies**: CLI tools often depend on formatter/linter configs. Detect and ensure they're not in .dockerignore. +4. **Static Asset Mapping**: Backend code may hardcode expected paths. Detect and map frontend outputs correctly. +5. **Multi-Stage for Native Modules**: If project has Rust/C++ native modules, use separate build stage for caching. + +### User Feedback + +> "From this experience, is there anything that can be converged into the skill so similar projects succeed on first try?" + +**Lesson**: Custom CLI detection is CRITICAL for monorepo projects. Must be detected in analysis phase. + +--- + +## Consolidated Patterns for Monorepo Projects + +### Detection Checklist (Analysis Phase) + +```yaml +monorepo_detection: + # Step 1: Detect workspace type + workspace_type: pnpm | yarn | npm | turborepo | nx | lerna + + # Step 2: Detect custom CLI (CRITICAL) + custom_cli: + detected: true | false + name: "${DETECTED_CLI_NAME}" # e.g., turbo, nx, custom name + type: "standard | custom" + entry: "${CLI_ENTRY_PATH}" # e.g., tools/cli/bin/cli.js + build_syntax: "${BUILD_COMMAND}" # Detected build command pattern + + # Step 3: Detect CLI dependencies + cli_dependencies: + git_hash_required: true | false + git_hash_env: "${ENV_VAR_NAME}" # e.g., GITHUB_SHA, GIT_COMMIT + config_files: [] # Files that CLI depends on + postinstall_script: "${SCRIPT}" # If postinstall runs init + + # Step 4: Detect native modules + native_modules: + rust_required: true | false + packages: [] # List of native package paths + multi_arch: true | false + + # Step 5: Detect static asset paths + static_assets: + backend_expects: "${PATH}" # Where backend looks for static files + frontend_outputs: [] # Where frontend builds output to +``` + +### Generation Rules (Generate Phase) + +```dockerfile +# Rule 1: Use detected CLI for builds (NEVER assume standard workspace commands) +# Use: analysis.custom_cli.build_syntax +RUN ${analysis.custom_cli.build_syntax} + +# Rule 2: Set git hash bypass if required +# Only add if: analysis.custom_cli.dependencies.git_hash_required == true +ENV ${GIT_HASH_ENV}=docker-build + +# Rule 3: Keep config files (modify .dockerignore) +# Remove from .dockerignore: analysis.custom_cli.dependencies.config_files + +# Rule 4: Multi-stage for native modules if detected +# Only add if: analysis.native_modules.rust_required == true +FROM builder AS native-builder +RUN yarn workspace ${NATIVE_PACKAGE} build --target ${RUST_TARGET} + +# Rule 5: Map static assets correctly +# For each frontend_output, create COPY to backend_expects +COPY --from=builder /app/${frontend_output} ./${backend_expects} +``` + +### External Services Detection + +```yaml +external_services: + # Database detection + database: + postgres: + detection: "DATABASE_URL|POSTGRES_|prisma|drizzle|typeorm" + image: "postgres:16-alpine" + image_vector: "pgvector/pgvector:pg16" # Use if vector search detected + healthcheck: "pg_isready -U ${USER}" + mysql: + detection: "MYSQL_|mysql:|mysql2" + image: "mysql:8.0" + mongodb: + detection: "MONGODB_|mongodb:|mongoose" + image: "mongo:7" + + # Cache/Queue + redis: + detection: "REDIS_|ioredis|bull|bullmq" + image: "redis:7-alpine" + healthcheck: "redis-cli ping" + + # Search engines + search: + elasticsearch: + detection: "ELASTIC_|elasticsearch|@elastic" + image: "elasticsearch:8.11.0" + meilisearch: + detection: "MEILI_|meilisearch" + image: "getmeili/meilisearch:v1.5" + manticore: + detection: "MANTICORE|manticoresearch" + image: "manticoresearch/manticore:latest" + + # Object storage + s3: + detection: "S3_|MINIO_|@aws-sdk/client-s3" + image: "minio/minio:latest" + command: "server /data --console-address :9001" +``` + +--- + +## Updated Metrics After AFFiNE Case + +### Detection Success Rate + +| Pattern | Before AFFiNE | After AFFiNE | +|---------|---------------|--------------| +| Custom CLI | 0% | 95%+ | +| Git Hash Dependency | 0% | 95%+ | +| Config File Dependencies | 50% | 90%+ | +| Static Asset Mapping | 30% | 85%+ | +| Native Module (Rust) | 60% | 90%+ | + +### Iteration Reduction + +- **Complex Monorepo (AFFiNE-style)**: From 10+ to 3-5 iterations +- **Key Factor**: Detecting custom CLI in analysis phase eliminates 50%+ of build failures diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/monorepo-cli-patterns.md b/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/monorepo-cli-patterns.md new file mode 100644 index 00000000..c4f1be9f --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/monorepo-cli-patterns.md @@ -0,0 +1,367 @@ +# Monorepo Custom CLI Patterns + +## Overview + +Many large monorepo projects have custom CLI tools for building instead of standard `yarn workspace` commands. +Failing to detect and use these CLIs is a common cause of build failures. + +**Key Principle**: NEVER assume `yarn workspace build` works. Always detect the actual build system. + +## Detection Checklist + +### Step 1: Detect Known Monorepo Tools + +```bash +# Check for well-known monorepo CLIs +KNOWN_CLIS=( + "turbo" # Turborepo + "nx" # Nx + "lerna" # Lerna + "rush" # Rush +) + +for cli in "${KNOWN_CLIS[@]}"; do + if [ -f "node_modules/.bin/$cli" ] || grep -q "\"$cli\"" package.json; then + echo "Detected: $cli" + CLI_TYPE="standard" + CLI_NAME="$cli" + break + fi +done + +# Check for turbo.json (Turborepo indicator) +[ -f "turbo.json" ] && CLI_NAME="turbo" + +# Check for nx.json (Nx indicator) +[ -f "nx.json" ] && CLI_NAME="nx" + +# Check for lerna.json (Lerna indicator) +[ -f "lerna.json" ] && CLI_NAME="lerna" +``` + +### Step 2: Detect Custom CLI + +```bash +# If no standard CLI found, check for custom CLI in package.json scripts +# Look for scripts that define a single-word command +CUSTOM_CLI=$(jq -r ' + .scripts | to_entries[] | + select(.key | test("^[a-z]+$")) | + select(.value | test("^[a-z]+ |^r ")) | + .key +' package.json 2>/dev/null | head -1) + +# Check tools/ directory for CLI definitions +for dir in "tools/cli" "tools/scripts" "scripts/cli"; do + if [ -d "$dir" ]; then + CLI_ENTRY=$(find "$dir" -name "*.js" -o -name "*.ts" | head -1) + [ -n "$CLI_ENTRY" ] && CLI_TYPE="custom" + fi +done +``` + +### Step 3: Determine Build Syntax + +Each CLI has different syntax. Detection must determine the correct pattern: + +| CLI | Build Syntax | Filter Flag | +|-----|--------------|-------------| +| Turborepo | `yarn turbo run build --filter=` | `--filter=` | +| Nx | `yarn nx build ` | positional | +| Lerna | `yarn lerna run build --scope=` | `--scope=` | +| Rush | `rush build -t ` | `-t` | +| Custom | varies | detect from source | + +```bash +# Determine syntax based on CLI type +case "$CLI_NAME" in + turbo) BUILD_SYNTAX="yarn turbo run build --filter=\${PACKAGE}" ;; + nx) BUILD_SYNTAX="yarn nx build \${PROJECT}" ;; + lerna) BUILD_SYNTAX="yarn lerna run build --scope=\${PACKAGE}" ;; + rush) BUILD_SYNTAX="rush build -t \${PACKAGE}" ;; + *) + # Custom CLI - analyze source for flag patterns + if grep -rqE "\-p.*package|--package" tools/ 2>/dev/null; then + BUILD_SYNTAX="yarn $CLI_NAME build -p \${PACKAGE}" + elif grep -rqE "\-\-filter" tools/ 2>/dev/null; then + BUILD_SYNTAX="yarn $CLI_NAME build --filter=\${PACKAGE}" + else + BUILD_SYNTAX="yarn $CLI_NAME build \${PACKAGE}" + fi + ;; +esac +``` + +## Common CLI Patterns + +### Pattern 1: Turborepo + +**Detection:** +```bash +[ -f "turbo.json" ] && echo "Turborepo detected" +``` + +**Correct Build:** +```dockerfile +RUN yarn turbo run build --filter=@scope/web +RUN yarn turbo run build --filter=@scope/server +``` + +### Pattern 2: Nx + +**Detection:** +```bash +[ -f "nx.json" ] && echo "Nx detected" +``` + +**Correct Build:** +```dockerfile +RUN yarn nx build web +RUN yarn nx build server +``` + +### Pattern 3: Lerna + +**Detection:** +```bash +[ -f "lerna.json" ] && echo "Lerna detected" +``` + +**Correct Build:** +```dockerfile +RUN yarn lerna run build --scope=@scope/web +``` + +### Pattern 4: Custom CLI + +**Detection:** +```bash +# Custom CLI often defined in tools/ or has special script in package.json +if [ -d "tools/cli" ]; then + echo "Custom CLI detected" +fi +``` + +**Analysis Required:** +1. Find CLI entry point +2. Analyze source for argument parsing +3. Determine correct syntax + +**Example Build:** +```dockerfile +# Syntax varies by project - must be detected +RUN yarn ${CLI_NAME} build -p @scope/web +``` + +## Git Hash Dependency + +Many build tools require git commit hash for versioning. + +**Detection:** +```bash +# Common patterns for git hash usage +if grep -rqE "GITHUB_SHA|GIT_COMMIT|GIT_SHA|COMMIT_HASH|rev-parse|nodegit|simple-git" tools/ src/ scripts/ 2>/dev/null; then + echo "Git hash required" + # Find the specific env var name + GIT_ENV=$(grep -rohE "(GITHUB_SHA|GIT_COMMIT|GIT_SHA|COMMIT_HASH)" tools/ src/ scripts/ 2>/dev/null | sort -u | head -1) +fi +``` + +**Solution:** +```dockerfile +# Set environment variable to bypass git requirement +# Use the detected env var name, or default to GITHUB_SHA +ENV ${GIT_ENV:-GITHUB_SHA}=docker-build +``` + +## Configuration File Dependencies + +Custom CLIs often depend on configuration files. + +**Common Required Files:** +- `.prettierrc` / `.prettierignore` - Code formatting +- `.eslintrc.*` / `oxlint.json` - Linting +- `tsconfig.json` - TypeScript config +- `.editorconfig` - Editor settings + +**Detection:** +```bash +CONFIG_DEPS=() + +# Check what the CLI/build system references +for config in ".prettierrc" ".prettierignore" ".eslintrc.js" "oxlint.json" "tsconfig.json"; do + if [ -f "$config" ] && grep -rqE "${config#.}" tools/ scripts/ 2>/dev/null; then + CONFIG_DEPS+=("$config") + fi +done +``` + +**Fix .dockerignore:** +``` +# Comment out any config files that CLI needs: +# .prettierrc +# .prettierignore +# tsconfig.json +``` + +## postinstall Script Handling + +Many monorepos run initialization in postinstall. + +**Detection:** +```bash +POSTINSTALL=$(jq -r '.scripts.postinstall // ""' package.json) +if [ -n "$POSTINSTALL" ]; then + echo "postinstall script: $POSTINSTALL" +fi +``` + +**Options:** + +1. **Keep postinstall** (recommended): +```dockerfile +RUN yarn install --immutable --inline-builds +# postinstall runs automatically +``` + +2. **Skip postinstall, run manually**: +```dockerfile +RUN yarn install --immutable --ignore-scripts +RUN yarn ${CLI_NAME} init # or equivalent +``` + +## Native Module (Rust/NAPI-RS) Builds + +**Detection:** +```bash +# Check for Rust project +HAS_RUST=false +if [ -f "Cargo.toml" ] || ls packages/*/Cargo.toml 2>/dev/null; then + HAS_RUST=true +fi + +# Check for NAPI-RS +if grep -qE "@napi-rs|napi-derive" package.json Cargo.toml 2>/dev/null; then + HAS_NAPI_RS=true +fi +``` + +**Build Pattern:** +```dockerfile +# Install Rust toolchain +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/usr/local/cargo/bin:$PATH" + +# Install NAPI-RS build dependencies +RUN apt-get update && apt-get install -y clang llvm + +# Build with correct target (auto-detect architecture) +ARG TARGETARCH +RUN if [ "$TARGETARCH" = "arm64" ]; then \ + rustup target add aarch64-unknown-linux-gnu && \ + yarn workspace @scope/native build --target aarch64-unknown-linux-gnu; \ + else \ + rustup target add x86_64-unknown-linux-gnu && \ + yarn workspace @scope/native build --target x86_64-unknown-linux-gnu; \ + fi +``` + +## Static Assets Path Detection + +Backend servers often expect frontend builds at specific paths. + +**Detection:** +```bash +# Search for static path references in backend code +STATIC_PATH="" +if grep -rqE "static|public" packages/backend/ src/server/ 2>/dev/null; then + STATIC_PATH=$(grep -rohE "(static|public)" packages/backend/ src/server/ 2>/dev/null | head -1) +fi + +# Find frontend output directories +FRONTEND_OUTPUTS=$(find packages apps -name "dist" -type d 2>/dev/null) +``` + +**Solution:** +```dockerfile +# Copy frontend builds to where backend expects +# Paths detected from analysis +COPY --from=builder /app/${FRONTEND_OUTPUT} ./${BACKEND_EXPECTS} +``` + +## Analysis Output Format + +When analyzing a monorepo, produce this structured output: + +```yaml +custom_cli: + detected: true | false + name: "${CLI_NAME}" # Detected CLI name + type: "standard | custom" # Known tool or custom + entry: "${CLI_ENTRY}" # Path to CLI (if custom) + + build_syntax: "${BUILD_SYNTAX}" # Complete build command template + packages_to_build: + - name: "@scope/web" + command: "yarn ${CLI_NAME} build ..." + output: "packages/web/dist" + + dependencies: + git_hash: + required: true | false + env_var: "${GIT_ENV}" # e.g., GITHUB_SHA + fallback: "docker-build" + + config_files: [] # Files NOT to exclude in .dockerignore + + postinstall: + script: "${POSTINSTALL}" + recommendation: "keep | skip_and_run_manually" + + native_modules: + rust: true | false + packages: [] + multi_arch: true | false + + static_assets: + backend_expects: "${STATIC_PATH}" + frontend_outputs: [] +``` + +## Troubleshooting + +### "command not found: ${CLI_NAME}" + +**Cause:** CLI not installed or PATH not set. + +**Fix:** +```dockerfile +# Ensure dependencies installed first +RUN yarn install --immutable +# CLI available via yarn +RUN yarn ${CLI_NAME} build ... +``` + +### "Unknown Syntax Error" / "Invalid argument" + +**Cause:** Wrong CLI syntax. + +**Fix:** Verify syntax by checking CLI help or source code. + +### "Failed to open git repo" + +**Cause:** Build requires git hash but .git not in Docker context. + +**Fix:** +```dockerfile +ENV ${GIT_ENV}=docker-build +``` + +### "assets-manifest.json not found" or similar static file errors + +**Cause:** Frontend output not copied to correct location. + +**Fix:** +1. Verify frontend builds successfully +2. Check where backend expects static files +3. Add correct COPY command in Dockerfile diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/system-deps.md b/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/system-deps.md new file mode 100644 index 00000000..3c4ac5a2 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/knowledge/system-deps.md @@ -0,0 +1,190 @@ +# System Dependencies Mapping + +## NPM Packages → System Libraries + +### Image Processing + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `sharp` | `libvips-dev` | +| `canvas` | `build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev` | +| `@napi-rs/canvas` | `build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev` | +| `jimp` | (none - pure JS) | +| `gm` | `graphicsmagick` | +| `imagemagick` | `imagemagick` | + +### Database Clients (Native) + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `better-sqlite3` | `python3 make g++` | +| `sqlite3` | `python3 make g++` | +| `pg-native` | `libpq-dev` | + +### Cryptography + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `bcrypt` | `python3 make g++` | +| `argon2` | `python3 make g++` | +| `sodium-native` | `python3 make g++` | + +### General Native Addons + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| Any with `node-gyp` | `python3 make g++` | +| `@swc/*` | (prebuilt binaries, usually none) | +| `esbuild` | (prebuilt binaries, usually none) | + +### PDF/Document + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `pdf-lib` | (none - pure JS) | +| `pdfkit` | (none - pure JS) | +| `puppeteer` | `chromium-browser` (or use puppeteer with bundled chromium) | +| `playwright` | (use playwright install) | + +--- + +## Python Packages → System Libraries + +### Database + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `psycopg2` | `libpq-dev` | +| `psycopg2-binary` | (none - uses bundled libs) | +| `mysqlclient` | `default-libmysqlclient-dev` | +| `pymssql` | `freetds-dev` | + +### Image Processing + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `Pillow` | `libjpeg-dev libpng-dev libtiff-dev libwebp-dev` | +| `opencv-python` | `libgl1-mesa-glx libglib2.0-0` | +| `opencv-python-headless` | `libgl1-mesa-glx libglib2.0-0` | + +### Cryptography + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `cryptography` | `libssl-dev libffi-dev` | +| `pynacl` | `libsodium-dev` | + +### XML/HTML + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `lxml` | `libxml2-dev libxslt-dev` | +| `beautifulsoup4` | (none - pure Python) | + +### Scientific + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `numpy` | (prebuilt wheels, usually none) | +| `scipy` | `libopenblas-dev` (optional, for building from source) | +| `pandas` | (prebuilt wheels, usually none) | + +### ML/AI + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `torch` | (prebuilt wheels) | +| `tensorflow` | (prebuilt wheels) | +| `onnxruntime` | (prebuilt wheels) | + +--- + +## Go Packages → System Libraries + +Go typically compiles to static binaries, but some packages require CGO: + +| Package | Debian/Ubuntu Packages | +|---------|----------------------| +| `go-sqlite3` | `gcc` (for CGO build) | +| Most packages | (none - static compilation) | + +For CGO-enabled builds: +```dockerfile +# Build stage needs: +RUN apk add --no-cache gcc musl-dev # Alpine +RUN apt-get install -y gcc # Debian +``` + +For static builds (recommended): +```dockerfile +RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o main . +``` + +--- + +## Java → System Libraries + +Most dependencies are handled by Maven/Gradle. Rare cases: + +| Use Case | Debian/Ubuntu Packages | +|----------|----------------------| +| Native image (GraalVM) | `build-essential zlib1g-dev` | +| Fonts for PDF | `fontconfig fonts-dejavu` | + +--- + +## Detection Script + +Use this to detect which system packages are needed: + +```bash +#!/bin/bash +# Scan package.json for known native dependencies + +NATIVE_DEPS="" + +if grep -q '"sharp"' package.json; then + NATIVE_DEPS="$NATIVE_DEPS libvips-dev" +fi + +if grep -q '"canvas"\|"@napi-rs/canvas"' package.json; then + NATIVE_DEPS="$NATIVE_DEPS build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev" +fi + +if grep -q '"better-sqlite3"\|"bcrypt"\|"argon2"' package.json; then + NATIVE_DEPS="$NATIVE_DEPS python3 make g++" +fi + +if grep -q '"pg-native"' package.json; then + NATIVE_DEPS="$NATIVE_DEPS libpq-dev" +fi + +echo "Required system packages: $NATIVE_DEPS" +``` + +--- + +## Alpine vs Debian/Slim + +### When to use Debian Slim (Recommended) +- Projects with native dependencies (sharp, canvas, bcrypt) +- Next.js projects +- Complex Node.js applications + +### When to use Alpine +- Simple Go applications (static binary) +- Minimal Python scripts without native deps +- Size is critical and no native deps + +### Package Name Differences + +| Debian | Alpine | +|--------|--------| +| `python3` | `python3` | +| `make` | `make` | +| `g++` | `g++` | +| `build-essential` | `build-base` | +| `libvips-dev` | `vips-dev` | +| `libcairo2-dev` | `cairo-dev` | +| `libpango1.0-dev` | `pango-dev` | +| `libpq-dev` | `postgresql-dev` | diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/modules/analyze.md b/plugins/labring/sealos-skills/skills/dockerfile-skill/modules/analyze.md new file mode 100644 index 00000000..daffec70 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/modules/analyze.md @@ -0,0 +1,1211 @@ +# Module: Project Analysis + +## Purpose + +Analyze a project to extract all information needed for Dockerfile generation. + +## Execution Steps + +### Pre-loaded Context (Optional) + +When invoked from the `sealos-deploy` pipeline, Phase 1 analysis results may already +be available (loaded from `.sealos/analysis.json`). + +**If analysis.json data is available**, skip overlapping steps and use pre-loaded values: + +| Skip Step | Use pre-loaded value | +|-----------|---------------------| +| Step 1 (language/framework) | `language`, `framework` | +| Step 2 (package manager) | `package_manager` | +| Step 4 (port) | `port` | +| Step 5 (databases) | `databases` — only skip DB type detection; still detect S3, search engines, message queues | +| Step 7 (Docker config) | `has_dockerfile` — still check docker-compose.yml and .dockerignore | +| Step 8 (monorepo) | `complexity_tier` — if L1/L2, skip; if L3, still enumerate workspace packages | + +**Always run** (Dockerfile-specific, not in analysis.json): +Steps 3, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17 + +**If no pre-loaded data** (standalone `/dockerfile`): run all 17 steps as normal. + +### Step 1: Detect Language and Framework + +Check for these files in order: + +``` +package.json → Node.js (check for next/nuxt/express/koa/nest) +requirements.txt → Python (check for django/flask/fastapi) +pyproject.toml → Python (modern) +go.mod → Go +pom.xml → Java (Maven) +build.gradle → Java (Gradle) +Cargo.toml → Rust +composer.json → PHP +Gemfile → Ruby +``` + +For Node.js, additionally check: +- `next.config.*` → Next.js +- `nuxt.config.*` → Nuxt +- `nest-cli.json` → NestJS + +### Step 2: Detect Package Manager + +``` +package-lock.json → npm +yarn.lock → yarn +pnpm-lock.yaml → pnpm +bun.lockb → bun +``` + +### Step 3: Extract Build and Run Commands + +**Node.js**: Read `package.json` scripts: +```json +{ + "scripts": { + "build": "...", // Build command + "start": "...", // Production run command + "dev": "..." // Development (ignore) + } +} +``` + +**Python**: Check for: +- `Makefile` with build targets +- `setup.py` / `pyproject.toml` entry points +- Common patterns: `uvicorn`, `gunicorn`, `python app.py` + +**Go**: Standard `go build` → binary execution + +### Step 4: Detect Port + +Search patterns in source code: + +```bash +# Node.js +grep -rE "listen\s*\(\s*[0-9]+" --include="*.js" --include="*.ts" +grep -rE "PORT.*[0-9]+" --include="*.js" --include="*.ts" + +# Python +grep -rE "uvicorn.*port|flask.*port|run\(.*port" --include="*.py" + +# Go +grep -rE "ListenAndServe.*:" --include="*.go" +``` + +Common defaults: +- Express/Koa/Nest: 3000 +- Next.js: 3000 +- FastAPI/Django: 8000 +- Go: 8080 +- Spring Boot: 8080 + +### Step 5: Detect External Services + +**Purpose**: Auto-detect required external services for docker-compose generation. + +**Detection Method**: + +```bash +# ============================================ +# Database Detection +# ============================================ + +# PostgreSQL +if grep -rqE "DATABASE_URL|POSTGRES_|postgres:|pg_|prisma|drizzle|typeorm" . 2>/dev/null; then + DB_TYPE="postgres" + + # Check for vector extension requirement + if grep -rqE "pgvector|vector.*embedding|createIndex.*vector" . 2>/dev/null; then + DB_IMAGE="pgvector/pgvector:pg16" + else + DB_IMAGE="postgres:16-alpine" + fi +fi + +# MySQL +if grep -rqE "MYSQL_|mysql:|mysql2" . 2>/dev/null; then + DB_TYPE="mysql" + DB_IMAGE="mysql:8.0" +fi + +# MongoDB +if grep -rqE "MONGODB_|mongodb:|mongoose" . 2>/dev/null; then + DB_TYPE="mongodb" + DB_IMAGE="mongo:7" +fi + +# ============================================ +# Cache/Queue Detection +# ============================================ + +# Redis +if grep -rqE "REDIS_|ioredis|redis:|bull|bullmq|@upstash/redis" . 2>/dev/null; then + REDIS_REQUIRED=true + REDIS_IMAGE="redis:7-alpine" +fi + +# RabbitMQ +if grep -rqE "RABBITMQ_|amqp:|amqplib" . 2>/dev/null; then + RABBITMQ_REQUIRED=true + RABBITMQ_IMAGE="rabbitmq:3-management-alpine" +fi + +# ============================================ +# Object Storage Detection +# ============================================ + +# S3/MinIO +if grep -rqE "S3_|MINIO_|@aws-sdk/client-s3|aws-sdk.*S3" . 2>/dev/null; then + S3_REQUIRED=true + # Default to MinIO for self-hosted + S3_IMAGE="minio/minio:latest" +fi + +# ============================================ +# Search Engine Detection +# ============================================ + +# Elasticsearch +if grep -rqE "ELASTIC_|elasticsearch|@elastic/elasticsearch" . 2>/dev/null; then + SEARCH_TYPE="elasticsearch" + SEARCH_IMAGE="elasticsearch:8.11.0" +fi + +# Meilisearch +if grep -rqE "MEILI_|meilisearch" . 2>/dev/null; then + SEARCH_TYPE="meilisearch" + SEARCH_IMAGE="getmeili/meilisearch:v1.5" +fi + +# ManticoreSearch +if grep -rqE "MANTICORE|manticoresearch|INDEXER_SEARCH_PROVIDER.*manticore" . 2>/dev/null; then + SEARCH_TYPE="manticore" + SEARCH_IMAGE="manticoresearch/manticore:latest" +fi +``` + +**Output**: +```yaml +external_services: + database: + type: "${DB_TYPE}" # postgres | mysql | mongodb | none + image: "${DB_IMAGE}" # Recommended Docker image + env_var: "DATABASE_URL" # Primary connection env var + has_vector: true | false # If pgvector needed + + redis: + required: ${REDIS_REQUIRED} + image: "${REDIS_IMAGE}" + env_var: "REDIS_URL" + + s3: + required: ${S3_REQUIRED} + image: "${S3_IMAGE}" + provider: "minio | aws | custom" + env_vars: + - "S3_ENDPOINT" + - "S3_ACCESS_KEY" + - "S3_SECRET_KEY" + + search: + type: "${SEARCH_TYPE}" # elasticsearch | meilisearch | manticore | none + image: "${SEARCH_IMAGE}" + env_var: "${SEARCH_ENV_VAR}" + + message_queue: + type: "rabbitmq | kafka | none" + image: "${MQ_IMAGE}" +``` + +**docker-compose Generation Rules**: +- Only include services that were detected +- Use detected image variants (e.g., pgvector vs postgres) +- Set appropriate health checks for each service +- Configure proper networking between services +- Generate environment variable templates + +### Step 6: Detect System Library Requirements + +Check `package.json` dependencies for known native modules: + +| NPM Package | Requires | +|-------------|----------| +| sharp | libvips-dev | +| canvas / @napi-rs/canvas | build-essential, libcairo2-dev, libpango1.0-dev | +| better-sqlite3 | python3, make, g++ | +| bcrypt | python3, make, g++ | +| node-gyp (any) | python3, make, g++ | + +Check `requirements.txt` for Python: + +| Pip Package | Requires | +|-------------|----------| +| psycopg2 | libpq-dev | +| Pillow | libjpeg-dev, libpng-dev | +| cryptography | libssl-dev, libffi-dev | +| lxml | libxml2-dev, libxslt-dev | + +### Step 7: Check for Existing Docker Configuration + +Look for: +- `Dockerfile` / `Dockerfile.*` +- `docker-compose.yml` / `docker-compose.yaml` +- `.dockerignore` + +If found, extract key decisions for reference (DO NOT blindly copy). + +### Step 8: Detect Workspace/Monorepo Configuration + +**For pnpm workspaces**: +```bash +# Check for pnpm-workspace.yaml +if [ -f "pnpm-workspace.yaml" ]; then + # Parse workspace packages + grep -E "^\s*-\s+" pnpm-workspace.yaml +fi + +# Check for patches directory (pnpm patch feature) +if [ -d "patches" ]; then + PATCHES_DIR="patches" +fi +``` + +**For npm/yarn workspaces**: +```bash +# Check package.json workspaces field +grep -A 20 '"workspaces"' package.json +``` + +**For Turborepo**: +```bash +# Check for turbo.json +if [ -f "turbo.json" ]; then + MONOREPO_TYPE="turborepo" +fi +``` + +**Output for workspace analysis**: +```yaml +workspace: + enabled: true + type: "pnpm" # pnpm | npm | yarn | turborepo | nx + config_file: "pnpm-workspace.yaml" + packages: + - "packages/**" + - "apps/desktop/src/main" + - "e2e" + patches_dir: "patches" # pnpm patched dependencies + required_files: # Files that MUST be copied for workspace to work + - "pnpm-workspace.yaml" + - "patches/**" + - "packages/*/package.json" + - "apps/desktop/src/main/package.json" + - "e2e/package.json" +``` + +### Step 9: Detect Package Manager Configuration + +**Check .npmrc / .yarnrc.yml**: +```bash +# Check for lockfile=false (common in some projects) +if grep -q "lockfile=false" .npmrc 2>/dev/null; then + LOCKFILE_DISABLED=true +fi + +# Check for other important settings +grep -E "^(resolution-mode|public-hoist-pattern|shamefully-hoist)" .npmrc +``` + +**Output**: +```yaml +package_manager_config: + lockfile_disabled: true # If lockfile=false in .npmrc + config_file: ".npmrc" + special_settings: + - "lockfile=false" + - "resolution-mode=highest" +``` + +### Step 10: Detect Build-Time Environment Variables + +**Scan for build-time required env vars**: +```bash +# Check for env validation in config files +grep -rE "process\.env\.\w+" src/ --include="*.ts" --include="*.js" | \ + grep -E "(throw|required|must be set)" | \ + grep -oE "process\.env\.\w+" | sort -u + +# Check for @t3-oss/env-nextjs or similar +grep -rE "createEnv|z\.string\(\)" src/env* 2>/dev/null +``` + +**Common build-time required vars for Next.js**: +- `KEY_VAULTS_SECRET` - Database encryption +- `DATABASE_URL` - For static page generation with DB access +- `AUTH_SECRET` - Authentication + +**Output**: +```yaml +build_time_env: + required: + - name: KEY_VAULTS_SECRET + source: "src/libs/server-config/db.ts" + placeholder: "build-placeholder-32chars" + - name: DATABASE_URL + source: "src/libs/server-config/db.ts" + placeholder: "postgres://placeholder:placeholder@localhost:5432/placeholder" + optional: + - name: NEXT_PUBLIC_API_URL + default: "http://localhost:3000" +``` + +### Step 11: Detect Custom Scripts and Entry Points + +**Check for docker-specific scripts**: +```bash +# Look for docker-specific build scripts +grep -E '"build:docker"|"start:docker"|"docker"' package.json + +# Check for custom server entry points +ls -la scripts/serverLauncher/*.js 2>/dev/null +ls -la scripts/*/startServer.js 2>/dev/null +``` + +**Output**: +```yaml +custom_scripts: + build: "npm run build:docker" # Prefer docker-specific if exists + start: "node startServer.js" + entry_point: "scripts/serverLauncher/startServer.js" + migrations: "scripts/migrateServerDB/docker.cjs" +``` + +### Step 12: Detect Database Migration System + +**Purpose**: Critical to detect migrations BEFORE Dockerfile generation to prevent runtime failures. + +**Detection Checklist**: +```bash +# 1. Check for migration directories +MIGRATION_DIRS=( + "packages/database/migrations" + "prisma/migrations" + "drizzle" + "migrations" + "db/migrations" + "database/migrations" +) + +for dir in "${MIGRATION_DIRS[@]}"; do + if [ -d "$dir" ]; then + MIGRATION_DIR="$dir" + MIGRATION_COUNT=$(find "$dir" -name "*.sql" -o -name "*.ts" -o -name "*.js" | wc -l) + break + fi +done + +# 2. Detect ORM type +if [ -f "prisma/schema.prisma" ]; then + ORM="prisma" + MIGRATION_CMD="npx prisma migrate deploy" +elif [ -f "drizzle.config.ts" ]; then + ORM="drizzle" + MIGRATION_CMD="npx drizzle-kit migrate" +elif grep -q "typeorm" package.json; then + ORM="typeorm" + MIGRATION_CMD="npx typeorm migration:run" +else + ORM="unknown" +fi + +# 3. Check if migrations run at build time or runtime +if grep -q "build-migrate" package.json; then + MIGRATION_TIME="build" +elif grep -q "MIGRATION_DB" .env.example 2>/dev/null; then + MIGRATION_TIME="runtime" + MIGRATION_ENV_VAR="MIGRATION_DB=1" +else + MIGRATION_TIME="none" # CRITICAL WARNING +fi + +# 4. Check for Next.js Standalone mode + ORM combination +if grep -q "output.*standalone" next.config.* 2>/dev/null && [ "$ORM" != "unknown" ]; then + # CRITICAL: Standalone mode doesn't include all node_modules + # ORM dependencies must be installed separately + STANDALONE_WITH_ORM=true +fi +``` + +**Output**: +```yaml +migration_system: + detected: true + orm: "drizzle" # prisma | drizzle | typeorm | unknown + migration_dir: "packages/database/migrations" + migration_count: 76 + migration_files: + - "0000_init.sql" + - "0049_better_auth.sql" + - "...76 files total" + + execution_timing: "runtime" # build | runtime | none + execution_command: "npx drizzle-kit migrate" + execution_env_var: "MIGRATION_DB=1" + + # Critical pattern detection + standalone_with_orm: true + requires_separate_deps: true # If true, must install ORM separately + + warnings: + - "Next.js standalone mode + ORM detected - ORM must be installed separately" + - "76 migration files found - ensure they run before first request" + - "No automatic migration detected - must add runtime migration" +``` + +**Warning Conditions**: +- `migration_count > 0` AND `migration_time == "none"` → **CRITICAL: Migrations will never run** +- `standalone_with_orm == true` AND `requires_separate_deps == false` → **Migrations will fail silently** +- `orm == "unknown"` AND `migration_count > 0` → **Cannot determine migration method** + +### Step 13: Analyze Build Script Complexity + +**Purpose**: Detect memory-intensive or unnecessary build steps to prevent OOM failures. + +**Detection Method**: +```bash +# 1. Parse build script from package.json +BUILD_SCRIPT=$(jq -r '.scripts.build' package.json) + +# 2. Check for heavy operations +HEAVY_OPS=() + +if echo "$BUILD_SCRIPT" | grep -qE "lint|eslint"; then + HEAVY_OPS+=("lint") +fi + +if echo "$BUILD_SCRIPT" | grep -qE "type-check|tsc.*--noEmit"; then + HEAVY_OPS+=("type-check") +fi + +if echo "$BUILD_SCRIPT" | grep -qE "test|jest|vitest"; then + HEAVY_OPS+=("test") +fi + +if echo "$BUILD_SCRIPT" | grep -qE "sitemap|buildSitemap"; then + HEAVY_OPS+=("sitemap") +fi + +# 3. Check workspace package count (memory multiplier) +if [ -f "pnpm-workspace.yaml" ]; then + WORKSPACE_COUNT=$(grep -E "^\s*-\s+" pnpm-workspace.yaml | wc -l) + if [ "$WORKSPACE_COUNT" -gt 20 ]; then + MEMORY_RISK="high" + elif [ "$WORKSPACE_COUNT" -gt 10 ]; then + MEMORY_RISK="medium" + fi +fi +``` + +**Output**: +```yaml +build_complexity: + build_script: "npm run prebuild && next build" + heavy_operations: + - name: "lint" + location: "prebuild" + essential: false + memory_usage: "2-4GB" + recommendation: "Skip in Docker build (run in CI)" + - name: "type-check" + location: "prebuild" + essential: false + memory_usage: "4-8GB" + recommendation: "Skip in Docker build (run in CI)" + - name: "sitemap" + location: "build" + essential: false + memory_usage: "500MB" + recommendation: "Skip in Docker (not needed for container)" + + workspace_package_count: 39 + memory_risk: "high" # low | medium | high + + recommendations: + optimized_build: "npx tsx scripts/prebuild.mts && npx next build --webpack" + memory_limit: "NODE_OPTIONS=--max-old-space-size=8192" + rationale: "Skip lint/type-check/sitemap to reduce memory from 12GB+ to 4GB" +``` + +### Step 14: Detect Custom CLI Tools + +**Purpose**: Many monorepos use custom CLI tools instead of standard workspace commands. +Using the wrong build command is a common cause of failure. + +**Why This Matters**: +- Standard `yarn workspace @pkg build` may not work +- Custom CLI may require specific flags/syntax +- Build outputs may go to non-standard locations +- CLI may depend on git hash, config files, or initialization scripts + +**Detection Method**: + +```bash +# Step 1: Detect well-known monorepo CLIs +KNOWN_CLIS=("turbo" "nx" "lerna" "rush") +for cli in "${KNOWN_CLIS[@]}"; do + if [ -f "node_modules/.bin/$cli" ] || grep -q "\"$cli\"" package.json; then + CLI_NAME="$cli" + CLI_TYPE="standard" + break + fi +done + +# Step 2: Detect custom CLI in root package.json scripts +# Look for scripts that invoke a single command (potential CLI) +CUSTOM_CLI=$(jq -r '.scripts | to_entries[] | select(.key | test("^[a-z]+$")) | select(.value | test("^[a-z]+ ")) | .key' package.json 2>/dev/null | head -1) +if [ -n "$CUSTOM_CLI" ] && [ "$CLI_TYPE" != "standard" ]; then + CLI_NAME="$CUSTOM_CLI" + CLI_TYPE="custom" +fi + +# Step 3: Check for CLI definition in tools/ or scripts/ +for dir in "tools/cli" "tools/scripts" "scripts/cli"; do + if [ -d "$dir" ]; then + CLI_ENTRY=$(find "$dir" -name "*.js" -o -name "*.ts" | head -1) + if [ -n "$CLI_ENTRY" ]; then + CLI_TYPE="custom" + break + fi + fi +done + +# Step 4: Analyze how packages are built +# Check if individual packages use CLI internally +for pkg_json in packages/*/package.json apps/*/package.json; do + if [ -f "$pkg_json" ]; then + BUILD_CMD=$(jq -r '.scripts.build // ""' "$pkg_json") + if [ -n "$BUILD_CMD" ] && ! echo "$BUILD_CMD" | grep -qE "^(tsc|webpack|next|vite|esbuild)"; then + # Non-standard build command, might use custom CLI + CUSTOM_BUILD_DETECTED=true + fi + fi +done + +# Step 5: Determine build syntax by examining usage patterns +if [ "$CLI_TYPE" = "standard" ]; then + case "$CLI_NAME" in + turbo) BUILD_SYNTAX="yarn turbo run build --filter=\${PACKAGE}" ;; + nx) BUILD_SYNTAX="yarn nx build \${PROJECT}" ;; + lerna) BUILD_SYNTAX="yarn lerna run build --scope=\${PACKAGE}" ;; + rush) BUILD_SYNTAX="rush build -t \${PACKAGE}" ;; + esac +elif [ "$CLI_TYPE" = "custom" ]; then + # Analyze CLI source or README to determine syntax + # Common patterns: -p for package, --filter, positional argument + if grep -rqE "\-p.*package|--package" tools/ 2>/dev/null; then + BUILD_SYNTAX="yarn $CLI_NAME build -p \${PACKAGE}" + else + BUILD_SYNTAX="yarn $CLI_NAME build \${PACKAGE}" + fi +fi +``` + +**Git Hash Dependency Detection**: +```bash +# Check if build requires git hash +GIT_HASH_REQUIRED=false +GIT_HASH_ENV="" + +# Common patterns for git hash usage +if grep -rqE "GITHUB_SHA|GIT_COMMIT|GIT_SHA|COMMIT_HASH" tools/ src/ scripts/ 2>/dev/null; then + GIT_HASH_REQUIRED=true + GIT_HASH_ENV=$(grep -rohE "(GITHUB_SHA|GIT_COMMIT|GIT_SHA|COMMIT_HASH)" tools/ src/ scripts/ 2>/dev/null | sort -u | head -1) +fi + +# Check for nodegit, simple-git, or git command usage +if grep -rqE "nodegit|simple-git|Repository.*open|git.*rev-parse" tools/ src/ 2>/dev/null; then + GIT_HASH_REQUIRED=true + [ -z "$GIT_HASH_ENV" ] && GIT_HASH_ENV="GITHUB_SHA" +fi +``` + +**Configuration File Dependencies**: +```bash +# Detect which config files the CLI/build system depends on +CONFIG_DEPS=() + +# Check for prettier dependency +if grep -rqE "prettier|\.prettierrc" tools/ scripts/ 2>/dev/null; then + [ -f ".prettierrc" ] && CONFIG_DEPS+=(".prettierrc") + [ -f ".prettierignore" ] && CONFIG_DEPS+=(".prettierignore") +fi + +# Check for eslint/oxlint dependency +if grep -rqE "eslint|oxlint" tools/ scripts/ 2>/dev/null; then + [ -f ".eslintrc.js" ] && CONFIG_DEPS+=(".eslintrc.js") + [ -f "oxlint.json" ] && CONFIG_DEPS+=("oxlint.json") +fi + +# Check for tsconfig dependency (almost always needed) +if grep -rqE "tsconfig|typescript" tools/ scripts/ 2>/dev/null; then + [ -f "tsconfig.json" ] && CONFIG_DEPS+=("tsconfig.json") +fi + +# Check postinstall script for init commands +POSTINSTALL=$(jq -r '.scripts.postinstall // ""' package.json) +if [ -n "$POSTINSTALL" ]; then + POSTINSTALL_RUNS_INIT=true +fi +``` + +**Static Assets Path Detection**: +```bash +# Detect where backend expects static files +BACKEND_STATIC_PATH="" +FRONTEND_OUTPUTS=() + +# Search for static path references in backend code +STATIC_REFS=$(grep -rohE "(static|public|dist|assets).*manifest|readHtmlAssets|webAssets" packages/backend/ src/server/ 2>/dev/null) +if [ -n "$STATIC_REFS" ]; then + BACKEND_STATIC_PATH=$(echo "$STATIC_REFS" | grep -oE "(static|public)" | head -1) +fi + +# Detect frontend build output directories +for frontend_dir in packages/frontend/*/dist apps/*/dist packages/*/.next; do + if [ -d "$frontend_dir" ] 2>/dev/null || grep -q "output.*dist" "$(dirname $frontend_dir)/package.json" 2>/dev/null; then + FRONTEND_OUTPUTS+=("$frontend_dir") + fi +done +``` + +**Output**: +```yaml +custom_cli: + detected: true + name: "${CLI_NAME}" # Detected CLI name + type: "${CLI_TYPE}" # standard | custom + entry: "${CLI_ENTRY}" # Path to CLI entry point (if custom) + + build_syntax: "${BUILD_SYNTAX}" # Complete build command template + # The ${PACKAGE} placeholder should be replaced with actual package names + + packages_to_build: # Detected packages that need building + - name: "@scope/web" + build_cmd: "yarn ${CLI_NAME} build -p @scope/web" + output_dir: "packages/web/dist" + - name: "@scope/server" + build_cmd: "yarn ${CLI_NAME} build -p @scope/server" + output_dir: "packages/server/dist" + + dependencies: + git_hash_required: ${GIT_HASH_REQUIRED} + git_hash_env: "${GIT_HASH_ENV}" # e.g., GITHUB_SHA, GIT_COMMIT + git_hash_fallback: "docker-build" + + config_files: ${CONFIG_DEPS} # Files that must NOT be in .dockerignore + # e.g., [".prettierrc", ".prettierignore", "tsconfig.json"] + + postinstall_runs_init: ${POSTINSTALL_RUNS_INIT} + + static_assets: + backend_expects: "${BACKEND_STATIC_PATH}" # e.g., "static", "public" + frontend_outputs: ${FRONTEND_OUTPUTS} # Source paths to copy from + # Generation phase will create COPY commands to map outputs to expected paths +``` + +**Warning Conditions**: +- `custom_cli.detected == true` AND analysis uses `yarn workspace` → **CRITICAL: Wrong build command** +- `git_hash_required == true` AND git_hash_env not set in Dockerfile → **Build will fail** +- `config_files` items found in `.dockerignore` → **CLI init will fail** +- `frontend_outputs` != `backend_expects` → **Runtime ENOENT errors** + +**Key Principle**: +The goal is to DETECT the patterns, not hardcode specific project names. The detection should work for ANY monorepo by analyzing: +1. What CLI is being used (by checking scripts, dependencies, and file structure) +2. What syntax that CLI requires (by analyzing CLI source or documentation) +3. What dependencies the build system has (git hash, config files, etc.) +4. Where outputs are generated and expected (static asset mapping) + +### Step 15: Detect Rust/Native Module Requirements + +**Purpose**: Some projects include Rust native modules that require special build setup. + +**Detection Method**: +```bash +# 1. Check for Cargo files +if [ -f "Cargo.toml" ] || ls packages/*/Cargo.toml 2>/dev/null; then + HAS_RUST=true +fi + +# 2. Check for napi-rs dependencies +if grep -qE "@napi-rs|napi-derive" package.json Cargo.toml 2>/dev/null; then + HAS_NAPI_RS=true +fi + +# 3. Detect native package location +NATIVE_PACKAGES=$(find packages -name "Cargo.toml" -exec dirname {} \;) +``` + +**Output**: +```yaml +native_modules: + rust_required: true + napi_rs: true + packages: + - path: "packages/backend/native" + name: "@scope/native" + build_command: "yarn workspace @scope/native build" + targets: + - "x86_64-unknown-linux-gnu" + - "aarch64-unknown-linux-gnu" + + build_dependencies: + - clang + - llvm + - pkg-config + - libssl-dev +``` + +### Step 16: Validate Build Command Dependencies + +**Purpose**: Detect build commands that will fail due to missing config files in Docker context, and automatically determine the resolved build command. + +**Core Principle**: +If a config file is in `.dockerignore`, the user intentionally excluded it. Respect that decision by skipping commands that depend on it. + +**Detection Method**: +```bash +# Command → Required config files mapping +declare -A CMD_CONFIG_DEPS=( + ["lint"]=".eslintrc.js .eslintrc.json .eslintrc.cjs eslint.config.js eslint.config.mjs" + ["eslint"]=".eslintrc.js .eslintrc.json .eslintrc.cjs eslint.config.js eslint.config.mjs" + ["type-check"]="tsconfig.json" + ["tsc --noEmit"]="tsconfig.json" + ["stylelint"]=".stylelintrc .stylelintrc.js .stylelintrc.json .stylelintrc.cjs" + ["prettier --check"]=".prettierrc .prettierrc.js .prettierrc.json .prettierrc.cjs" + ["jest"]="jest.config.js jest.config.ts jest.config.mjs" + ["vitest"]="vitest.config.ts vitest.config.js vitest.config.mts" +) + +# Parse build-related scripts +PREBUILD=$(jq -r '.scripts.prebuild // ""' package.json) +BUILD=$(jq -r '.scripts.build // ""' package.json) + +# Split script by && or ; into individual commands +# For each command: +# 1. Check if it matches any key in CMD_CONFIG_DEPS +# 2. If yes, find which config file it needs +# 3. Check if config file exists in project +# 4. Check if config file is excluded in .dockerignore +# 5. Determine action: keep or skip + +resolve_command() { + local script="$1" + local resolved="" + + # Split by && + IFS='&&' read -ra COMMANDS <<< "$script" + + for cmd in "${COMMANDS[@]}"; do + cmd=$(echo "$cmd" | xargs) # trim whitespace + should_skip=false + skip_reason="" + + for pattern in "${!CMD_CONFIG_DEPS[@]}"; do + if echo "$cmd" | grep -q "$pattern"; then + config_files="${CMD_CONFIG_DEPS[$pattern]}" + + # Check if any required config exists + config_found="" + for cfg in $config_files; do + if [ -f "$cfg" ]; then + config_found="$cfg" + break + fi + done + + if [ -z "$config_found" ]; then + # Config file doesn't exist + should_skip=true + skip_reason="Config file not found" + elif [ -f ".dockerignore" ] && grep -qE "^${config_found}$|^${config_found%.*}\." .dockerignore; then + # Config file excluded in .dockerignore + should_skip=true + skip_reason="Config file excluded in .dockerignore" + fi + break + fi + done + + if [ "$should_skip" = false ]; then + if [ -n "$resolved" ]; then + resolved="$resolved && $cmd" + else + resolved="$cmd" + fi + fi + done + + echo "$resolved" +} +``` + +**Decision Logic**: +``` +For each command in build script: + │ + ├── Command needs config file? + │ │ + │ ├── NO → Keep command + │ │ + │ └── YES → Config file exists? + │ │ + │ ├── NO → Skip command (config doesn't exist) + │ │ + │ └── YES → Config in .dockerignore? + │ │ + │ ├── YES → Skip command (user excluded it) + │ │ + │ └── NO → Keep command +``` + +**Output**: +```yaml +build_command_resolution: + original_prebuild: "tsx scripts/prebuild.mts && npm run lint" + original_build: "cross-env NODE_OPTIONS=... next build" + + commands: + - cmd: "tsx scripts/prebuild.mts" + requires_config: "scripts/prebuild.mts" + config_status: "available" + action: "keep" + + - cmd: "npm run lint" + requires_config: ".eslintrc.js" + config_status: "excluded_in_dockerignore" + action: "skip" + skip_reason: "Config file .eslintrc.js excluded in .dockerignore" + + # Final resolved command for Dockerfile + resolved_prebuild: "tsx scripts/prebuild.mts" + resolved_build: "next build --webpack" + + # Full docker build command + docker_build_command: "npx tsx scripts/prebuild.mts && npx cross-env NODE_OPTIONS=--max-old-space-size=8192 npx next build --webpack" + + skipped_commands: + - command: "npm run lint" + reason: "Config file .eslintrc.js excluded in .dockerignore" +``` + +**Key Rules**: +1. **Respect .dockerignore**: If user excluded a config file, skip commands that need it +2. **No user interaction**: Automatically determine which commands to skip +3. **Document skipped commands**: Record what was skipped and why for Dockerfile comments +4. **Prefix with npx**: Use `npx` for CLI tools to ensure they're found in Docker + +### Step 17: Determine Complexity Level + +**L1 (Simple)**: +- Single language +- No build step OR simple build (just `npm install`) +- No external service dependencies +- No workspace +- No migrations +- Examples: Express API, simple Python script + +**L2 (Medium)**: +- Has build step (Next.js, TypeScript compilation, etc.) +- Has external services (Database, Redis) +- May have environment variable requirements at build time +- Simple migration system +- Examples: Next.js app, Django with PostgreSQL + +**L3 (Complex)**: +- Monorepo structure (pnpm-workspace, turborepo, nx) +- Multi-language (Python backend + Node frontend) +- Complex build pipeline +- Many external dependencies +- Has build-time env var requirements +- Custom entry points / server launchers +- Complex migration system (76+ migrations, ORM dependencies) +- Examples: Large monorepo projects, enterprise applications + +## Output Format + +```yaml +analysis: + language: "typescript" + framework: "nextjs" + framework_version: "16.x" + package_manager: "pnpm" + package_manager_version: "10.20.0" + + # Package manager configuration + package_manager_config: + lockfile_disabled: true # lockfile=false in .npmrc + config_file: ".npmrc" + install_command: "pnpm install" # NOT --frozen-lockfile if lockfile disabled + + # Workspace / Monorepo configuration + workspace: + enabled: true + type: "pnpm" + config_file: "pnpm-workspace.yaml" + packages: + - "packages/**" + - "apps/desktop/src/main" + - "e2e" + patches_dir: "patches" + required_copy_files: # Files MUST be copied for workspace + - path: "pnpm-workspace.yaml" + dest: "./" + - path: "patches" + dest: "./patches" + - path: "packages" + dest: "./packages" + - path: "e2e/package.json" + dest: "./e2e/" + - path: "apps/desktop/src/main/package.json" + dest: "./apps/desktop/src/main/" + + # Build configuration + build: + command: "npm run build:docker" # Prefer docker-specific script + fallback_command: "npm run build" + output_dir: ".next" + standalone_mode: true # output: 'standalone' in next.config + env_required: # Build-time required env vars + - name: KEY_VAULTS_SECRET + placeholder: "build-placeholder-32chars" + - name: DATABASE_URL + placeholder: "postgres://placeholder:placeholder@localhost:5432/placeholder" + - name: AUTH_SECRET + placeholder: "build-placeholder-auth-secret" + + # Runtime configuration + run: + command: "node startServer.js" + entry_point: "scripts/serverLauncher/startServer.js" + port: 3210 + env_required: + - DATABASE_URL + - KEY_VAULTS_SECRET + - AUTH_SECRET + + # External dependencies + dependencies: + external_services: + - type: postgres + env_var: DATABASE_URL + recommended_image: "pgvector/pgvector:pg16" + - type: redis + env_var: REDIS_URL + optional: true + - type: s3 + env_var: S3_ENDPOINT + optional: true + system_libs: + - name: sharp + packages: ["libvips-dev"] + - name: "@napi-rs/canvas" + packages: ["build-essential", "libcairo2-dev", "libpango1.0-dev"] + + # Files that must NOT be excluded from docker build context + required_files: + - ".npmrc" # Package manager config + - "pnpm-workspace.yaml" + - "patches/**" + - "scripts/prebuild.mts" + - "scripts/serverLauncher/**" + - "scripts/migrateServerDB/**" + - "scripts/_shared/**" + - "packages/database/migrations/**" + + # Existing docker configuration analysis + existing_docker: + has_dockerfile: false + has_compose: false + key_decisions: [] + + # Database migration system + migration_system: + detected: true + orm: "drizzle" + migration_dir: "packages/database/migrations" + migration_count: 76 + execution_timing: "runtime" + execution_env_var: "MIGRATION_DB=1" + standalone_with_orm: true + requires_separate_deps: true + warnings: + - "Next.js standalone mode + Drizzle ORM - must install drizzle-orm separately" + - "76 migration files - ensure runtime execution before first request" + + # Build complexity analysis + build_complexity: + build_script: "npm run prebuild && next build" + heavy_operations: + - name: "lint" + essential: false + recommendation: "Skip in Docker (run in CI)" + - name: "type-check" + essential: false + recommendation: "Skip in Docker (run in CI)" + workspace_package_count: 39 + memory_risk: "high" + optimized_build: "npx tsx scripts/prebuild.mts && npx next build --webpack" + memory_limit: "NODE_OPTIONS=--max-old-space-size=8192" + + # Build command resolution (from Step 16) + build_command_resolution: + original_prebuild: "tsx scripts/prebuild.mts && npm run lint" + original_build: "cross-env NODE_OPTIONS=... next build" + resolved_prebuild: "tsx scripts/prebuild.mts" + resolved_build: "next build --webpack" + docker_build_command: "npx tsx scripts/prebuild.mts && npx cross-env NODE_OPTIONS=--max-old-space-size=8192 npx next build --webpack" + skipped_commands: + - command: "npm run lint" + reason: "Config file .eslintrc.js excluded in .dockerignore" + + # Complexity assessment + complexity: "L3" + max_iterations: 5 + + # Warnings / Notes + warnings: + - "Project uses lockfile=false - cannot use --frozen-lockfile" + - "Workspace packages must be copied individually for Docker cache optimization" + - "Build requires placeholder env vars for Next.js static generation" + - "CRITICAL: Migration system detected - must handle ORM dependencies separately" + - "Build script includes heavy operations - optimize to prevent OOM" +``` + +## Artifact Output + +After completing all 17 analysis steps and displaying the analysis summary in conversation, +write the structured artifact to disk: + +**File**: `docker-build/analysis.json` + +**Instructions**: +1. Create the `docker-build/` directory if it does not exist: `Bash: mkdir -p docker-build` +2. Write the JSON file using the Write tool with path `docker-build/analysis.json` +3. Populate all fields from the analysis results gathered in Steps 1–17 +4. Set `generated_at` to current ISO-8601 timestamp +5. Set `detection_steps_completed` to 17 (or the actual count if any steps were skipped) + +**Schema**: + +```json +{ + "schema_version": "1.0", + "generated_at": "", + "phase": "analysis", + "project": { + "language": "typescript", + "framework": "nextjs", + "framework_version": "14.x", + "package_manager": "pnpm", + "package_manager_version": "10.20.0" + }, + "package_manager_config": { + "lockfile_disabled": false, + "config_file": ".npmrc", + "install_command": "pnpm install --frozen-lockfile" + }, + "workspace": { + "enabled": true, + "type": "pnpm", + "config_file": "pnpm-workspace.yaml", + "packages": ["packages/**", "apps/**"], + "patches_dir": "patches", + "package_count": 39 + }, + "build": { + "command": "npm run build", + "resolved_command": "npx tsx scripts/prebuild.mts && npx next build --webpack", + "output_dir": ".next", + "standalone_mode": true, + "env_required": [ + { "name": "KEY_VAULTS_SECRET", "placeholder": "build-placeholder-32chars" } + ] + }, + "run": { + "command": "node server.js", + "entry_point": "scripts/serverLauncher/startServer.js", + "port": 3210, + "env_required": ["DATABASE_URL", "KEY_VAULTS_SECRET", "AUTH_SECRET"] + }, + "external_services": { + "database": { + "type": "postgres", + "image": "pgvector/pgvector:pg16", + "env_var": "DATABASE_URL", + "has_vector": true + }, + "redis": { "required": false }, + "s3": { "required": false }, + "search": { "type": "none" }, + "message_queue": { "type": "none" } + }, + "system_libs": [ + { "npm_package": "sharp", "apt_packages": ["libvips-dev"] } + ], + "migration_system": { + "detected": true, + "orm": "drizzle", + "migration_dir": "packages/database/migrations", + "migration_count": 76, + "execution_timing": "runtime", + "execution_command": "npx drizzle-kit migrate", + "execution_env_var": "MIGRATION_DB=1", + "standalone_with_orm": true, + "requires_separate_deps": true, + "warnings": [ + "Next.js standalone mode + Drizzle ORM - must install drizzle-orm separately", + "76 migration files - ensure runtime execution before first request" + ] + }, + "build_complexity": { + "original_build_script": "npm run prebuild && next build", + "heavy_operations": [ + { "name": "lint", "essential": false, "recommendation": "Skip in Docker" }, + { "name": "type-check", "essential": false, "recommendation": "Skip in Docker" } + ], + "workspace_package_count": 39, + "memory_risk": "high", + "docker_build_command": "npx tsx scripts/prebuild.mts && npx next build --webpack", + "memory_limit": "NODE_OPTIONS=--max-old-space-size=8192", + "skipped_commands": [ + { "command": "npm run lint", "reason": "Config file .eslintrc.js excluded in .dockerignore" } + ] + }, + "custom_cli": { "detected": false }, + "native_modules": { "rust_required": false }, + "complexity": "L3", + "max_iterations": 5, + "existing_docker": { + "has_dockerfile": false, + "has_compose": false, + "has_dockerignore": false + }, + "warnings": [ + "CRITICAL: Migration system detected - must handle ORM dependencies separately" + ], + "detection_steps_completed": 17 +} +``` + +The top-level keys are: `schema_version`, `generated_at`, `phase`, `project`, `package_manager_config`, +`workspace`, `build`, `run`, `external_services`, `system_libs`, `migration_system`, +`build_complexity`, `custom_cli`, `native_modules`, `complexity`, `max_iterations`, +`existing_docker`, `warnings`, `detection_steps_completed`. + +Populate each key from the corresponding analysis step results. Use actual detected values — the JSON +example above is illustrative (based on a Next.js monorepo) and your output must reflect the actual project. + +**Note**: This file is written to `docker-build/` to keep artifacts separate from the generated +Docker files (Dockerfile, .dockerignore, etc.) which remain at the project root. diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/modules/build-fix.md b/plugins/labring/sealos-skills/skills/dockerfile-skill/modules/build-fix.md new file mode 100644 index 00000000..f1fe1e30 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/modules/build-fix.md @@ -0,0 +1,560 @@ +# Module: Build Validation & Fix + +## Purpose + +Execute docker buildx build (targeting linux/amd64), capture errors, and automatically fix Dockerfile issues through iterative refinement. + +## Execution Flow + +``` +┌─────────────────────┐ +│ docker buildx build │ +└──────────┬──────────┘ + │ + ┌──────┴──────┐ + │ │ + SUCCESS FAILURE + │ │ + ▼ ▼ + OUTPUT ┌─────────────┐ + FINAL │ Parse Error │ + FILES └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ Match Pattern│ + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ Apply Fix │ + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ iteration++ │ + │ < max? │ + └──────┬──────┘ + │ + ┌───────┴───────┐ + │ │ + YES NO + │ │ + ▼ ▼ + RETRY OUTPUT BEST + + WARN USER +``` + +## Build Command + +```bash +docker buildx build --platform linux/amd64 --load -t test-build:latest . 2>&1 +``` + +**Important**: Capture both stdout and stderr for error analysis. + +## Error Pattern Matching + +See [knowledge/error-patterns.md](../knowledge/error-patterns.md) for the full pattern database. + +### Priority 1: File/Directory Not Found + +**Pattern**: +``` +ENOENT: no such file or directory, open '...' +Error: Cannot find module '...' +FileNotFoundError: [Errno 2] No such file or directory: '...' +``` + +**Fix Actions**: +1. Extract the missing path from error message +2. If it's a config file (*.json, *.yaml, *.toml): + ```dockerfile + RUN mkdir -p /app/data && echo '{}' > /app/data/config.json + ``` +3. If it's a directory: + ```dockerfile + RUN mkdir -p /app/missing-dir + ``` + +### Priority 2: Environment Variable Missing + +**Pattern**: +``` +`XXX` is not set +Error: XXX environment variable is required +KeyError: 'XXX' +``` + +**Fix Actions**: +1. Extract variable name +2. Add to build stage with placeholder: + ```dockerfile + ARG XXX=placeholder_for_build + ENV XXX=$XXX + ``` + +### Priority 3: Out of Memory + +**Pattern**: +``` +Killed +Exit code: 137 +JavaScript heap out of memory +FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed +``` + +**Fix Actions**: +1. Add memory options: + ```dockerfile + ENV NODE_OPTIONS="--max-old-space-size=4096" + ``` +2. If still failing, increase to 8192 + +### Priority 4: Native Module Build Failed + +**Pattern**: +``` +gyp ERR! +node-gyp rebuild +error: command 'gcc' failed +ModuleNotFoundError: No module named 'distutils' +``` + +**Fix Actions**: +1. Add build tools to deps stage: + ```dockerfile + RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* + ``` + +### Priority 5: Package-Specific Errors + +**Pattern**: `sharp`, `vips`, `canvas` related errors + +**Fix Actions**: +```dockerfile +# For sharp +RUN apt-get update && apt-get install -y --no-install-recommends \ + libvips-dev \ + && rm -rf /var/lib/apt/lists/* + +# For canvas +RUN apt-get update && apt-get install -y --no-install-recommends \ + libcairo2-dev \ + libpango1.0-dev \ + libjpeg-dev \ + libgif-dev \ + librsvg2-dev \ + && rm -rf /var/lib/apt/lists/* +``` + +### Priority 6: Permission Denied + +**Pattern**: +``` +EACCES: permission denied +PermissionError: [Errno 13] +``` + +**Fix Actions**: +1. Check if file operations happen before USER switch +2. Add ownership change: + ```dockerfile + RUN chown -R node:node /app + USER node + ``` + +### Priority 7: Network/Download Errors + +**Pattern**: +``` +ETIMEDOUT +ECONNREFUSED +npm ERR! network +Could not resolve host +``` + +**Fix Actions**: +1. Add retry logic or timeout increase: + ```dockerfile + RUN npm ci --network-timeout 600000 + ``` +2. Consider adding mirror/proxy if consistently failing + +### Priority 8: Shell Syntax Error + +**Pattern**: +``` +/bin/sh: syntax error +unexpected EOF +``` + +**Fix Actions**: +1. Check for unescaped special characters +2. Avoid complex shell substitutions in RUN +3. Use heredoc syntax for multi-line scripts: + ```dockerfile + RUN < -d -c "\dt" + +# Expected output: List of tables (users, sessions, messages, etc.) +# If "Did not find any relations" → MIGRATIONS FAILED + +# Check migration status table +docker-compose exec postgres psql -U -d -c "SELECT * FROM drizzle_migrations LIMIT 5;" + +# Verify migration count +MIGRATION_COUNT=$(docker-compose exec postgres psql -U -d -t -c "SELECT COUNT(*) FROM drizzle_migrations;") +EXPECTED_COUNT=76 # From analysis phase + +if [ "$MIGRATION_COUNT" -ne "$EXPECTED_COUNT" ]; then + echo "CRITICAL: Only $MIGRATION_COUNT of $EXPECTED_COUNT migrations ran" +fi +``` + +### Step 3: Application Health Validation + +```bash +# Test HTTP response +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3210) + +# Acceptable codes: 200 (OK), 302 (Redirect to login), 401 (Auth required) +# Unacceptable: 500 (Internal error), 502 (Bad gateway) + +if [ "$HTTP_CODE" = "500" ]; then + echo "CRITICAL: Application returning 500 error" + docker-compose logs app +fi + +# Test specific API endpoint +curl -v http://localhost:3210/api/health +``` + +### Step 4: Log Analysis + +```bash +# Check for common error patterns +docker-compose logs app | grep -E "error|Error|ERROR|failed|Failed|FAILED" | tail -20 + +# Check for migration-related errors +docker-compose logs app | grep -E "migration|migrate|schema|relation.*does not exist" + +# Check for database connection +docker-compose logs app | grep -E "database|postgres|connection" +``` + +### Validation Checklist + +Before declaring success: + +**Build Phase**: +- [ ] Image builds successfully (`docker buildx build` exits 0) +- [ ] Image size reasonable (< 2GB for most apps) + +**Startup Phase**: +- [ ] Container starts without crash +- [ ] All ports accessible +- [ ] Database connection successful + +**Migration Phase** - CRITICAL: +- [ ] Database tables exist (verify with `\dt`) +- [ ] Migration count matches expected (e.g., 76/76) +- [ ] Migration status table populated +- [ ] No "relation does not exist" errors + +**Runtime Phase** : +- [ ] App returns 200/302/401, not 500 +- [ ] Health check endpoint works +- [ ] Core API endpoint accessible +- [ ] No runtime errors in logs + +**Failure Conditions** → Don't declare success if: +- Database tables missing +- App returns 500 errors +- Logs show "relation does not exist" +- Migration count mismatch +- Container crashes on startup + +## Output on Success + +``` +## Build Results + +Build successful! +Container started successfully! +Database migrations completed (76/76) +Application health check passed + +### Generated Files +- Dockerfile +- .dockerignore +- docker-compose.yml +- .env.docker.local (auto-generated with test secrets) +- docker-entrypoint.sh +- DOCKER.md (deployment guide) + +### Validation Results +- Image size: ~1.4GB +- Container status: UP (healthy) +- Database tables: 23 tables created +- HTTP response: 302 (redirecting to /signin) +- Migrations: 76/76 completed + +### Quick Start +cd /path/to/project +docker-compose up -d + +# Access application +open http://localhost:3210 + +### Build Command +docker buildx build --platform linux/amd64 --load -t your-app:latest . + +### Run Command +docker run -d -p 3000:3000 your-app:latest + +### Next Steps +1. Application is ready to use +2. Set real API keys in .env.docker.local for production +3. Review DOCKER.md for production deployment guide +``` + +## Output on Failure + +``` +## Build Results + +Build completed with issues after 3 iterations. + +### Last Error +[error message] + +### Attempted Fixes +1. Added missing directory /app/data +2. Injected environment variable XXX +3. Added memory limit increase + +### Manual Steps Required +- Review the error above +- The generated Dockerfile may need manual adjustment for: [specific issue] + +### Partial Output +The best version of Dockerfile is saved. It may work with additional configuration. +``` + +## Artifact Output + +After completing all build iterations AND all runtime validation steps (Phase 3 + Phase 4), +write two artifact files to the `docker-build/` directory. + +### Phase 3: Build Result + +**File**: `docker-build/build-result.json` + +**Instructions**: +1. Write this file after the build loop completes (whether success or failure) +2. Create the `docker-build/` directory if it does not exist: `Bash: mkdir -p docker-build` +3. `outcome`: `"success"` | `"partial_success"` (max iterations reached but last Dockerfile saved) | `"failed"` +4. `iterations`: one entry per docker build execution, with `status`, `error_category`, + `error_excerpt` (first 200 chars of error), `fix_applied`, `duration_seconds` +5. `fixes_applied`: only include iterations where a fix was applied +6. `errors_not_matched`: include the raw error text for any error where no pattern from + `knowledge/error-patterns.md` matched — these are candidates for new patterns +7. `build.image_size_mb`: capture from `docker images` output after successful build + +**Schema**: + +```json +{ + "schema_version": "1.0", + "generated_at": "", + "phase": "build", + "outcome": "success", + "iterations": [ + { + "iteration": 1, + "status": "failed", + "error_category": "env_var_missing", + "error_excerpt": "KEY_VAULTS_SECRET is not set", + "fix_applied": "Added ARG KEY_VAULTS_SECRET=build-placeholder with ENV", + "duration_seconds": 47 + }, + { + "iteration": 2, + "status": "success", + "error_category": null, + "error_excerpt": null, + "fix_applied": null, + "duration_seconds": 184 + } + ], + "total_iterations": 2, + "max_iterations_allowed": 5, + "complexity_level": "L3", + "build": { + "command": "docker buildx build --platform linux/amd64 --load -t test-build:latest .", + "image_name": "test-build:latest", + "image_size_mb": 1420, + "image_size_human": "1.42GB", + "exit_code": 0 + }, + "fixes_applied": [ + { + "iteration": 1, + "pattern_matched": "env_var_missing", + "error": "KEY_VAULTS_SECRET is not set", + "fix": "Added ARG/ENV placeholder for KEY_VAULTS_SECRET in build stage" + } + ], + "errors_not_matched": [] +} +``` + +### Phase 4: Validation Result + +**File**: `docker-build/validation-result.json` + +**Instructions**: +1. Write this file after ALL validation steps (Steps 1–4 of Post-Build Validation) complete +2. If build failed entirely, still write this file with `outcome: "skipped"` and all checklist items false +3. `migration.applicable`: set to `false` if `analysis.migration_system.detected == false` +4. `http_health.acceptable`: true if HTTP code is 200, 302, or 401; false if 500, 502, 503 +5. `checklist`: each boolean maps directly to a checkbox in the Validation Checklist above +6. `summary`: one sentence describing the overall outcome + +**Schema**: + +```json +{ + "schema_version": "1.0", + "generated_at": "", + "phase": "validation", + "outcome": "success", + "container_startup": { + "status": "healthy", + "compose_command": "docker-compose up -d", + "wait_seconds": 30, + "all_containers_up": true, + "crash_detected": false + }, + "migration": { + "applicable": true, + "orm": "drizzle", + "expected_count": 76, + "actual_count": 76, + "tables_exist": true, + "tables_verified": ["users", "sessions", "messages", "agents"], + "status": "success", + "verification_command": "docker-compose exec postgres psql -U app -d app -c \"\\dt\"" + }, + "http_health": { + "url": "http://localhost:3210", + "http_code": 302, + "acceptable": true, + "notes": "302 redirect to /signin — expected for unauthenticated request" + }, + "log_analysis": { + "error_count": 0, + "migration_errors": [], + "critical_errors": [], + "warnings": [] + }, + "checklist": { + "image_built": true, + "container_started": true, + "database_connected": true, + "migrations_executed": true, + "tables_created": true, + "http_valid_response": true, + "no_runtime_errors": true, + "healthcheck_passed": true + }, + "summary": "All validation checks passed. App is fully operational." +} +``` + +If the build failed entirely (Phase 3 outcome = `"failed"`), write this file with: +```json +{ + "schema_version": "1.0", + "generated_at": "", + "phase": "validation", + "outcome": "skipped", + "reason": "Build failed — validation cannot proceed without a working image", + "checklist": { + "image_built": false, + "container_started": false, + "database_connected": false, + "migrations_executed": false, + "tables_created": false, + "http_valid_response": false, + "no_runtime_errors": false, + "healthcheck_passed": false + }, + "summary": "Validation skipped because the Docker image failed to build." +} +``` diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/modules/generate.md b/plugins/labring/sealos-skills/skills/dockerfile-skill/modules/generate.md new file mode 100644 index 00000000..fd322f6c --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/modules/generate.md @@ -0,0 +1,1172 @@ +# Module: Dockerfile Generation + +## Purpose + +Generate production-ready Dockerfile based on analysis results. + +## Input + +Project analysis from `analyze.md` module. + +## Generation Rules + +### Rule 1: Select Base Template + +Based on `analysis.framework` and `analysis.package_manager`: + +| Framework | Package Manager | Template | +|-----------|-----------------|----------| +| express / koa / nestjs | npm/yarn/pnpm | [templates/nodejs-express.dockerfile](../templates/nodejs-express.dockerfile) | +| nextjs | npm/yarn/pnpm | [templates/nodejs-nextjs.dockerfile](../templates/nodejs-nextjs.dockerfile) | +| nextjs | bun | [templates/nodejs-nextjs-bun.dockerfile](../templates/nodejs-nextjs-bun.dockerfile) | +| nuxt | any | [templates/nodejs-nuxt.dockerfile](../templates/nodejs-nuxt.dockerfile) | +| fastapi / flask | any | [templates/python-fastapi.dockerfile](../templates/python-fastapi.dockerfile) | +| django | any | [templates/python-django.dockerfile](../templates/python-django.dockerfile) | +| go (any) | any | [templates/golang.dockerfile](../templates/golang.dockerfile) | +| springboot | any | [templates/java-springboot.dockerfile](../templates/java-springboot.dockerfile) | + +**Package Manager Detection**: +- `bun.lockb` → Bun +- `pnpm-lock.yaml` → pnpm +- `yarn.lock` → Yarn +- `package-lock.json` → npm + +### Rule 2: Apply Best Practices + +Every generated Dockerfile MUST include: + +1. **Fixed version tags** (NEVER use `latest`) + ```dockerfile + # Good + FROM node:20.11.1-slim + + # Bad + FROM node:latest + FROM node:lts + ``` + +2. **Multi-stage build** (when applicable) + ```dockerfile + FROM node:20-slim AS deps + FROM deps AS build + FROM node:20-slim AS runtime + ``` + +3. **Cache optimization** + ```dockerfile + # Copy dependency files first + COPY package.json package-lock.json ./ + RUN npm ci + + # Then copy source + COPY . . + ``` + +4. **BuildKit cache mounts** (for package managers) + ```dockerfile + RUN --mount=type=cache,target=/root/.npm npm ci + RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt + RUN --mount=type=cache,target=/go/pkg/mod go build + ``` + +5. **Non-root user** + ```dockerfile + USER node # Node.js + USER appuser # Python (create first) + USER nobody # Go (statically compiled) + ``` + +6. **Minimal runtime image** + - Node.js: `node:XX-slim` (not alpine, better compatibility) + - Python: `python:3.XX-slim` + - Go: `alpine` or `scratch` + - Java: `eclipse-temurin:XX-jre-alpine` + +7. **Clean package manager cache** + ```dockerfile + RUN apt-get update && apt-get install -y ... \ + && rm -rf /var/lib/apt/lists/* + ``` + +8. **HEALTHCHECK** (without installing curl) + ```dockerfile + # Node.js + HEALTHCHECK CMD node -e "require('http').get('http://127.0.0.1:3000/health')" + + # Python + HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')" + ``` + +### Rule 3: Handle System Dependencies + +If `analysis.dependencies.system_libs` is not empty: + +```dockerfile +# Build stage - install build tools +FROM node:20-slim AS deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Runtime stage - minimal packages only +FROM node:20-slim AS runtime +# Only install runtime libs if needed (e.g., libvips for sharp) +``` + +### Rule 4: Handle External Services + +If `analysis.dependencies.external_services` is not empty: + +1. Generate `docker-compose.yml` with required services +2. Document required environment variables +3. Add health checks for dependent services + +### Rule 5: Framework-Specific Handling + +#### Next.js Special Rules + +**Rule 1: Always Enable Standalone Output (Recommended)** + +Standalone mode can reduce image size by 80-90% (e.g., from 3GB to 350MB). + +1. **Check next.config.{js,mjs,ts}** for existing `output: 'standalone'` +2. **If not present, automatically add it**: + ```javascript + // next.config.mjs + const nextConfig = { + output: 'standalone', // Add this line + // ... other config + } + ``` + +3. **Standalone Mode Dockerfile**: + ```dockerfile + # Only copy standalone output, no full node_modules needed + COPY --from=builder /app/public ./public + COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ + COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + CMD ["node", "server.js"] + ``` + +4. **Non-Standalone Mode** (not recommended, large image): + ```dockerfile + COPY --from=build /app/.next ./.next + COPY --from=build /app/node_modules ./node_modules + CMD ["npm", "start"] + ``` + +**Rule 2: Detect SDK Initialization in API Routes** + +Next.js statically analyzes all routes during build. If an API route has top-level SDK initialization: +```typescript +// app/api/mail/route.ts +const resend = new Resend(process.env.RESEND_API_KEY); // Executes at build time! +``` + +**Detection Method**: +```bash +# Scan for process.env usage in API routes +grep -r "process\.env\.\w\+" app/api/ --include="*.ts" --include="*.tsx" +``` + +**Fix**: Add placeholder environment variables in build stage +```dockerfile +# Build stage - add placeholders (only for passing static analysis) +ARG RESEND_API_KEY=re_placeholder_key +ARG NOTION_SECRET=placeholder_secret +ENV RESEND_API_KEY=${RESEND_API_KEY} +ENV NOTION_SECRET=${NOTION_SECRET} +# Actual values are injected at runtime via docker run -e or compose +``` + +**Common SDKs Requiring Placeholders**: +- Resend: `RESEND_API_KEY` +- Stripe: `STRIPE_SECRET_KEY` +- Notion: `NOTION_SECRET`, `NOTION_DB` +- Upstash: `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` +- Supabase: `SUPABASE_URL`, `SUPABASE_KEY` + +#### Database Migration Handling + +**Entrypoint Templates**: When generating `docker-entrypoint.sh`, use the matching template from +`/templates/assets/` as the starting point instead of writing from scratch: + +| ORM detected | Template | +|-------------|----------| +| Drizzle | `docker-entrypoint-drizzle.sh` | +| Prisma | `docker-entrypoint-prisma.sh` | +| TypeORM | `docker-entrypoint-typeorm.sh` | +| None (no migrations) | `docker-entrypoint-plain.sh` | + +Replace `{{MIGRATION_DIR}}`, `{{ENTRY_FILE}}`, and `{{DATASOURCE_PATH}}` with actual project values. +You may add project-specific steps (e.g., seed data, cache warmup), but preserve the template's +`set -e`, echo markers, and `exec` for proper signal handling. + +**Critical Pattern**: Next.js Standalone + ORM Dependencies + +**Problem**: Next.js standalone output doesn't include all `node_modules`. If your app uses an ORM (Drizzle, Prisma, TypeORM), the ORM packages won't be available for migrations. + +**Detection** (from analysis phase): +```yaml +migration_system: + standalone_with_orm: true + requires_separate_deps: true + orm: "drizzle" +``` + +**Solution 1: Separate Dependencies Installation (Recommended)** + +Pattern for Next.js Standalone + ORM: + +```dockerfile +# Build stage +FROM node:20-slim AS build +WORKDIR /app + +# ... build steps ... + +# : Install ORM dependencies separately for migrations +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + mkdir -p /deps && \ + cd /deps && \ + pnpm add pg drizzle-orm --ignore-scripts + +# Continue with Next.js build +RUN npm run build + +# Production stage +FROM node:20-slim AS production +WORKDIR /app + +# Copy standalone output +COPY --from=build /app/.next/standalone ./ + +# : Copy ORM dependencies separately +COPY --from=build /deps/node_modules/pg ./node_modules/pg +COPY --from=build /deps/node_modules/drizzle-orm ./node_modules/drizzle-orm + +# Copy migration files +COPY --from=build /app/packages/database/migrations ./packages/database/migrations + +# Create startup script +COPY --chown=nextjs:nodejs docker-entrypoint.sh /app/ +RUN chmod +x /app/docker-entrypoint.sh + +CMD ["/app/docker-entrypoint.sh"] +``` + +**Solution 2: Runtime Migration with Separate Deps** + +For ORMs that support runtime migration: + +```bash +# docker-entrypoint.sh +#!/bin/sh +set -e + +echo "Running database migrations..." + +# Use separately installed ORM packages +export NODE_PATH=/app/node_modules:/deps/node_modules + +# For Drizzle +if [ -f "/app/packages/database/migrations" ]; then + node -e "require('drizzle-orm/node-postgres').migrate(...)" +fi + +# For Prisma +if [ -f "/app/prisma/schema.prisma" ]; then + npx prisma migrate deploy +fi + +echo "Starting application..." +exec node server.js +``` + +**Solution 3: SQL File Direct Execution (Fallback)** + +If ORM approach fails, execute SQL files directly: + +```dockerfile +# Copy SQL migration files +COPY --from=build /app/packages/database/migrations/*.sql ./migrations/ + +# In entrypoint or compose healthcheck +# for file in /app/migrations/*.sql; do +# psql $DATABASE_URL < $file +# done +``` + +**ORM-Specific Patterns**: + +```yaml +# Drizzle +dependencies: + - pg + - drizzle-orm +migration_command: "node -r drizzle-orm/node-postgres ..." + +# Prisma +dependencies: + - prisma + - @prisma/client +migration_command: "npx prisma migrate deploy" + +# TypeORM +dependencies: + - typeorm + - pg +migration_command: "npx typeorm migration:run" +``` + +#### Build Optimization Handling + +**Problem**: Build scripts often include CI tasks (lint, type-check) that consume excessive memory in Docker. + +**Detection** (from analysis phase): +```yaml +build_complexity: + heavy_operations: ["lint", "type-check", "sitemap"] + memory_risk: "high" + optimized_build: "npx tsx scripts/prebuild.mts && npx next build" +``` + +**Solution: Skip Non-Essential Build Steps** + +```dockerfile +# Build stage +FROM node:20-slim AS build + +# Increase memory limit based on complexity +ENV NODE_OPTIONS="--max-old-space-size=8192" + +# Don't run full build script with CI tasks +# RUN npm run build # This includes lint, type-check, etc. + +# Run only essential build steps +RUN npx tsx scripts/prebuild.mts && \ + npx cross-env NODE_OPTIONS=--max-old-space-size=8192 npx next build --webpack + +# Skip optional build artifacts +# - Sitemap generation (not needed in Docker) +# - Type checking (should be in CI) +# - Linting (should be in CI) +``` + +**Memory Optimization Strategy**: + +```yaml +Workspace Package Count: + 0-10 packages: NODE_OPTIONS=--max-old-space-size=4096 + 11-20 packages: NODE_OPTIONS=--max-old-space-size=6144 + 21+ packages: NODE_OPTIONS=--max-old-space-size=8192 + +Heavy Operations to Skip: + - lint/eslint: Run in CI, not in Docker build + - type-check: Run in CI, not in Docker build + - test: Run in CI, not in Docker build + - sitemap: Usually not needed in containerized deployment + - docs generation: Not needed for runtime +``` + +**Comment in Generated Dockerfile**: +```dockerfile +# NOTE: Build script optimized for Docker +# - Skipped: lint, type-check (should be done in CI/CD pipeline) +# - Skipped: sitemap generation (not required for containerized deployment) +# - Memory limit increased to 8192MB due to large workspace (39+ packages) +# - Only running essential build steps: prebuild + next build +``` + +#### Build Command Resolution (from Analysis Step 16) + +**Purpose**: Use the auto-resolved build command that skips commands with unavailable config files. + +**Input** (from analysis phase `build_command_resolution`): +```yaml +build_command_resolution: + docker_build_command: "npx tsx scripts/prebuild.mts && npx cross-env NODE_OPTIONS=--max-old-space-size=8192 npx next build --webpack" + skipped_commands: + - command: "npm run lint" + reason: "Config file .eslintrc.js excluded in .dockerignore" +``` + +**Generation Rules**: + +1. **Always use `docker_build_command` from analysis** - never use raw `npm run prebuild` or `npm run build`: + ```dockerfile + # Use the resolved command directly + RUN ${analysis.build_command_resolution.docker_build_command} + ``` + +2. **Add comments for skipped commands** - document what was skipped and why: + ```dockerfile + # Build the application + # Auto-resolved build command (commands with unavailable configs skipped) + # - Skipped: npm run lint (Config file .eslintrc.js excluded in .dockerignore) + RUN npx tsx scripts/prebuild.mts && \ + npx cross-env NODE_OPTIONS=--max-old-space-size=8192 npx next build --webpack + ``` + +3. **Always prefix CLI tools with `npx`** - ensures tools are found in Docker: + ```dockerfile + # Correct: use npx prefix + RUN npx tsx scripts/prebuild.mts && npx next build + + # Incorrect: may fail if not in PATH + RUN tsx scripts/prebuild.mts && next build + ``` + +**Why This Matters**: +- Build scripts often chain multiple commands: `prebuild && lint && build` +- Some commands (lint, type-check) require config files that may be in `.dockerignore` +- Instead of modifying `.dockerignore`, we skip commands that would fail +- This is detected in analysis phase Step 16 and resolved automatically + +**No User Interaction Required**: +- Analysis phase determines which commands to skip based on config file availability +- Generation phase simply uses the pre-resolved `docker_build_command` +- Comments document what was skipped for transparency + +#### Custom CLI Build Rules (L3 Monorepo) + +**CRITICAL**: Many monorepos use custom CLI tools. Using standard `yarn workspace` commands causes silent failures. + +**Detection** (from analysis phase Step 14): +```yaml +custom_cli: + detected: true + name: "${CLI_NAME}" # Detected CLI name (e.g., turbo, nx, custom name) + build_syntax: "${BUILD_CMD}" # Detected build command syntax +``` + +**Build Command Generation**: + +1. **If custom CLI detected**, use the detected CLI syntax from analysis: + ```dockerfile + # Use the exact build_syntax from analysis.custom_cli + RUN ${analysis.custom_cli.build_syntax} + ``` + + Common CLI patterns (for reference): + ```dockerfile + # Turborepo pattern: turbo run --filter= + RUN yarn turbo run build --filter=@scope/web + + # Nx pattern: nx + RUN yarn nx build web + + # Lerna pattern: lerna run --scope= + RUN yarn lerna run build --scope=@scope/web + + # Custom CLI pattern (varies by project) + RUN yarn ${CLI_NAME} build -p @scope/package + ``` + +2. **If git hash required** (`analysis.custom_cli.git_hash_required == true`), set bypass: + ```dockerfile + # Set BEFORE build commands + ENV ${analysis.custom_cli.git_hash_env}=docker-build + # Common: ENV GITHUB_SHA=docker-build + ``` + +3. **If config files required**, ensure NOT in .dockerignore: + ```dockerfile + # Copy config files detected as CLI dependencies + # Files from: analysis.custom_cli.config_files + COPY ${config_file} ./ + ``` + +4. **Map static assets** based on analysis: + ```dockerfile + # Copy frontend builds to where backend expects + # Source: analysis.custom_cli.static_assets.frontend_outputs + # Dest: analysis.custom_cli.static_assets.backend_expects + COPY --from=builder /app/${frontend_output} ./${backend_expects} + ``` + +**Detection Logic** (what analyze.md Step 14 provides): +```yaml +# Analysis output example: +custom_cli: + detected: true + name: "turbo" + entry: "node_modules/.bin/turbo" + build_syntax: "yarn turbo run build --filter=@scope/web" + + dependencies: + git_hash_required: false + config_files: [] + + static_assets: + backend_expects: "public" + frontend_outputs: + - src: "apps/web/dist" + dest: "public" +``` + +**WRONG patterns to avoid**: +```dockerfile +# WRONG: Using standard workspace command when custom CLI exists +RUN yarn workspace @scope/web build # May fail or produce wrong output + +# WRONG: Assuming build output locations without checking +COPY --from=builder /app/.next ./ # May not exist for custom build system +``` + +**Key Principle**: ALWAYS use the build command from `analysis.custom_cli.build_syntax`. Never assume standard patterns work. + +#### Rust/Native Module Build Stage + +**When `analysis.native_modules.rust_required == true`**: + +```dockerfile +# Separate native-builder stage +FROM builder AS native-builder + +WORKDIR /app + +# Install Rust toolchain +ENV RUSTUP_HOME=/usr/local/rustup \ + CARGO_HOME=/usr/local/cargo \ + PATH=/usr/local/cargo/bin:$PATH + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + +# Install build dependencies for NAPI-RS +RUN apt-get update && apt-get install -y --no-install-recommends \ + clang \ + llvm \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Set clang for tree-sitter compatibility +ENV CC="clang -D_BSD_SOURCE" \ + TARGET_CC="clang -D_BSD_SOURCE" + +# Build for correct architecture +ARG TARGETARCH +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + if [ "$TARGETARCH" = "arm64" ] || [ "$(uname -m)" = "aarch64" ]; then \ + rustup target add aarch64-unknown-linux-gnu && \ + yarn workspace @pkg/native build --target aarch64-unknown-linux-gnu; \ + else \ + rustup target add x86_64-unknown-linux-gnu && \ + yarn workspace @pkg/native build --target x86_64-unknown-linux-gnu; \ + fi +``` + +#### Monorepo Special Rules (L3) + +1. For Turborepo: + ```dockerfile + RUN npx turbo prune --scope= --docker + ``` + +2. For pnpm workspace: + ```dockerfile + # Copy workspace configuration + COPY package.json pnpm-workspace.yaml .npmrc ./ + + # Copy all workspace package.json files + COPY packages ./packages + COPY patches ./patches + COPY e2e/package.json ./e2e/ + COPY apps/desktop/src/main/package.json ./apps/desktop/src/main/ + + # Install - check if lockfile is disabled + # If lockfile=false in .npmrc: + RUN pnpm install --ignore-scripts + # Otherwise: + RUN pnpm install --frozen-lockfile --ignore-scripts + ``` + +3. For pnpm workspace with `lockfile=false`: + ```dockerfile + # IMPORTANT: Do NOT use --frozen-lockfile + # Check .npmrc for lockfile=false + RUN pnpm install --ignore-scripts + ``` + +4. Handle custom entry points: + ```dockerfile + # If project has custom startServer.js or similar + COPY --from=builder /app/scripts/serverLauncher/startServer.js ./startServer.js + COPY --from=builder /app/scripts/_shared ./scripts/_shared + + # Handle database migrations + COPY --from=builder /app/scripts/migrateServerDB/docker.cjs ./docker.cjs + COPY --from=builder /app/packages/database/migrations ./migrations + ``` + +5. Handle build-time environment variables: + ```dockerfile + # For Next.js apps that require env vars at build time + # Use ARG for build-time only (more secure than ENV) + ARG KEY_VAULTS_SECRET_PLACEHOLDER="build-placeholder-key-32chars" + ARG DATABASE_URL_PLACEHOLDER="postgres://placeholder:placeholder@localhost:5432/placeholder" + + ENV KEY_VAULTS_SECRET=${KEY_VAULTS_SECRET_PLACEHOLDER} + ENV DATABASE_URL=${DATABASE_URL_PLACEHOLDER} + ENV AUTH_SECRET=${KEY_VAULTS_SECRET_PLACEHOLDER} + ENV DATABASE_DRIVER="" + ``` + +6. Handle Node.js path compatibility: + ```dockerfile + # Some scripts hardcode /bin/node but node:slim has it in /usr/local/bin + RUN ln -sf /usr/local/bin/node /bin/node + ``` + +## Output Files + +### 1. Dockerfile + +Generated based on template + rules above. + +### 2. .dockerignore + +**IMPORTANT**: For workspace/monorepo projects, .dockerignore must be carefully crafted to: +1. Exclude unnecessary files for smaller context +2. BUT include all workspace package.json files +3. BUT include patches directory +4. BUT include required build scripts + +**Smart .dockerignore Generation Rules**: + +1. Check `analysis.workspace.required_copy_files` - these MUST NOT be excluded +2. Check `analysis.required_files` - these MUST NOT be excluded +3. Use negation patterns (`!`) to re-include specific files + +``` +# VCS +.git +.gitignore +.gitattributes + +# Dependencies (will be installed in container) +node_modules +**/node_modules +.pnpm-store + +# Build outputs (will be regenerated) +.next +out +dist +build +coverage +*.tsbuildinfo + +# Local environment +.env +.env.local +.env.*.local +# Keep example files for reference +!.env.example +!.env.docker.example + +# IDE +.vscode +.idea +*.swp +*.swo + +# Documentation (not needed for runtime) +docs +*.md +!README.md + +# Tests (for workspace, keep package.json only) +# IMPORTANT: Use e2e/* not e2e to allow !e2e/package.json +e2e/* +!e2e/package.json +tests +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx + +# Desktop app (keep package.json for workspace) +apps/desktop/node_modules +apps/desktop/dist +apps/desktop/out +# Note: Do NOT exclude apps/desktop entirely if it's a workspace package + +# CI/CD +.github +.gitlab +.circleci + +# Docker files (avoid recursion) +Dockerfile* +docker-compose*.yml +!docker-compose/ + +# Misc +.DS_Store +Thumbs.db +*.log +npm-debug.log* +.pnpm-debug.log* + +# Cache +.cache +.turbo +.eslintcache +.stylelintcache + +# Source maps (optional, may want for debugging) +# *.map + +# Scripts not needed for runtime +# IMPORTANT: Do NOT exclude scripts needed for build/start +# scripts/prebuild.mts # Needed for build +# scripts/serverLauncher # Needed for start +scripts/cdnWorkflow +scripts/changelogWorkflow +scripts/docsWorkflow +scripts/i18nWorkflow +scripts/mdxWorkflow +scripts/readmeWorkflow +``` + +**Validation Checklist for .dockerignore**: + +- [ ] All workspace package.json files are NOT excluded +- [ ] patches/ directory is NOT excluded (if pnpm patches used) +- [ ] .npmrc is NOT excluded (needed for pnpm config) +- [ ] Build scripts are NOT excluded (prebuild.mts, etc.) +- [ ] Server launcher scripts are NOT excluded +- [ ] Migration scripts are NOT excluded + +### 3. docker-compose.yml (if external services) + +**Auto-Detection Rules** (from analysis phase Step 5): + +Based on `analysis.dependencies.external_services`, generate appropriate service blocks: + +```yaml +# Service detection patterns: +postgres: + detection: "DATABASE_URL|POSTGRES_|prisma|drizzle|typeorm" + check_for_vector: "pgvector|vector.*embedding" # Use pgvector if detected + +redis: + detection: "REDIS_|ioredis|redis|bull|bullmq" + +s3/minio: + detection: "S3_|MINIO_|AWS_S3|@aws-sdk/client-s3" + +search_engines: + elasticsearch: "ELASTIC_|elasticsearch|@elastic" + meilisearch: "MEILI_|meilisearch" + manticore: "MANTICORE|manticoresearch" +``` + +**Template Generation**: + +```yaml +services: + # Main application + app: + build: + context: . + dockerfile: Dockerfile + container_name: ${PROJECT_NAME}-server + restart: unless-stopped + ports: + - "${APP_PORT:-3000}:${APP_PORT:-3000}" + environment: + # Auto-generated based on detected services + - DATABASE_URL=postgres://${DB_USER:-app}:${DB_PASS:-app}@postgres:5432/${DB_NAME:-app} + - REDIS_URL=redis://redis:6379 + # ... other env vars from analysis + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${APP_PORT}/api/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - app-network + + # PostgreSQL (if detected) + # Use pgvector/pgvector:pg16 if vector search detected + # Use postgres:16-alpine otherwise + postgres: + image: ${POSTGRES_IMAGE} # pgvector/pgvector:pg16 or postgres:16-alpine + container_name: ${PROJECT_NAME}-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=${DB_USER:-app} + - POSTGRES_PASSWORD=${DB_PASS:-app} + - POSTGRES_DB=${DB_NAME:-app} + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-app} -d ${DB_NAME:-app}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - app-network + + # Redis (if detected) + redis: + image: redis:7-alpine + container_name: ${PROJECT_NAME}-redis + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - app-network + + # MinIO (if S3 detected and MINIO preferred) + minio: + image: minio/minio:latest + container_name: ${PROJECT_NAME}-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + - MINIO_ROOT_USER=${MINIO_USER:-minioadmin} + - MINIO_ROOT_PASSWORD=${MINIO_PASS:-minioadmin} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - app-network + + # ManticoreSearch (if detected) + manticore: + image: manticoresearch/manticore:latest + container_name: ${PROJECT_NAME}-manticore + restart: unless-stopped + volumes: + - manticore-data:/var/lib/manticore + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9308"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + pgdata: + driver: local + redis-data: + driver: local + minio-data: + driver: local + manticore-data: + driver: local +``` + +**Generation Logic**: + +1. Start with base template (app service only) +2. For each detected service in `analysis.dependencies.external_services`: + - Add the corresponding service block + - Add to app's `depends_on` with health check condition + - Add corresponding volume + - Add environment variables to app service +3. Only include services that were detected +4. Use appropriate image variants (e.g., pgvector vs postgres) + +### 4. Environment Documentation + +Output a summary of required environment variables: + +``` +## Required Environment Variables + +### Build Time +(none required) + +### Runtime +- DATABASE_URL: PostgreSQL connection string +- REDIS_URL: Redis connection string (optional) +- PORT: Server port (default: 3000) +``` + +## Validation Checklist + +Before proceeding to build phase, verify: + +- [ ] Base image version is fixed (not `latest`) +- [ ] Multi-stage build is used (if build step exists) +- [ ] Non-root user is configured +- [ ] EXPOSE matches detected port +- [ ] CMD/ENTRYPOINT is correct +- [ ] .dockerignore excludes sensitive files + +**Programmatic validation** (if Node.js available): +```bash +node "/scripts/validate-dockerfile.mjs" "$WORK_DIR/Dockerfile" --port= --json +``` +This checks: no `:latest` tags, non-root USER, multi-stage build, COPY order, port match, no -dev packages in runtime, CMD exists, .dockerignore exists. +Fix any reported errors before proceeding to build. + +## Artifact Output + +After writing all Docker configuration files to disk, write two additional artifacts: + +### 1. Generate Report + +**File**: `docker-build/generate-report.json` + +**Instructions**: +1. Write immediately after all Docker files are created +2. Create the `docker-build/` directory if it does not exist: `Bash: mkdir -p docker-build` +3. Use the Write tool with path `docker-build/generate-report.json` +4. `files_generated`: list every file written in this phase with its path and a one-line description +5. `template_used`: the filename from `templates/` that was selected in Rule 1 +6. `key_decisions`: for each significant choice made (standalone mode, separate ORM deps, pgvector image, + memory limit, skipped build steps), write one entry with `decision` (short identifier) and + `rationale` (one sentence explaining why, based on what was detected in analysis) +7. `env_vars.build_time`: list env vars added as ARG/ENV placeholders +8. `env_vars.runtime_required`: list env vars the app needs at runtime (from `analysis.run.env_required`) +9. `validation_checklist`: set each boolean based on whether the generated Dockerfile satisfies it + +**Schema**: + +```json +{ + "schema_version": "1.0", + "generated_at": "", + "phase": "generate", + "files_generated": [ + { "path": "Dockerfile", "size_bytes": 3240, "description": "Multi-stage production Dockerfile" }, + { "path": ".dockerignore", "size_bytes": 892, "description": "Build context exclusions" }, + { "path": "docker-compose.yml", "size_bytes": 2100, "description": "Compose file with postgres, redis" }, + { "path": ".env.docker.local", "size_bytes": 580, "description": "Auto-generated test secrets" }, + { "path": "docker-entrypoint.sh", "size_bytes": 620, "description": "Startup script with migration handling" }, + { "path": "docker-build/deploy.md", "size_bytes": 1800, "description": "Deployment guide with env vars and run commands" } + ], + "template_used": "nodejs-nextjs.dockerfile", + "key_decisions": [ + { "decision": "standalone_mode", "rationale": "output: standalone detected in next.config.mjs — reduces image size ~80%" }, + { "decision": "separate_orm_deps", "rationale": "Standalone mode + Drizzle ORM requires drizzle-orm installed in /deps" }, + { "decision": "skip_lint_type_check", "rationale": "High memory risk with 39 workspace packages — CI tasks skipped in Docker build" }, + { "decision": "pgvector_image", "rationale": "pgvector extension usage detected — using pgvector/pgvector:pg16 instead of postgres:16-alpine" }, + { "decision": "node_options_8192", "rationale": "39 workspace packages — memory limit set to 8192MB" } + ], + "base_image": "node:20.11.1-slim", + "build_stages": ["deps", "build", "production"], + "exposed_port": 3210, + "non_root_user": "nextjs", + "env_vars": { + "build_time": [ + { "name": "KEY_VAULTS_SECRET", "type": "placeholder", "placeholder": "build-placeholder-32chars" }, + { "name": "DATABASE_URL", "type": "placeholder", "placeholder": "postgres://placeholder:placeholder@localhost:5432/placeholder" } + ], + "runtime_required": ["DATABASE_URL", "KEY_VAULTS_SECRET", "AUTH_SECRET"], + "runtime_optional": [ + { "name": "PORT", "default": "3210" }, + { "name": "REDIS_URL", "default": null, "optional": true } + ] + }, + "compose_services": ["app", "postgres"], + "validation_checklist": { + "fixed_base_image_version": true, + "multi_stage_build": true, + "non_root_user": true, + "expose_matches_detected_port": true, + "healthcheck_present": true, + "dockerignore_excludes_sensitive": true + } +} +``` + +### 2. Deployment Guide + +**File**: `docker-build/deploy.md` + +Write this file using the Write tool. Use the following template, replacing all `[BRACKETED]` placeholders +with actual values from the analysis and generation results. Do not leave any brackets in the output file. + +**Template**: + +````markdown +# Deployment Guide + +Generated by the Dockerfile skill on [TIMESTAMP]. + +--- + +## Required Environment Variables + +These must be set for the application to start. The app will fail without them. + +| Variable | Description | Example | +|----------|-------------|---------| +| `DATABASE_URL` | PostgreSQL connection string | `postgres://user:pass@localhost:5432/myapp` | +| `KEY_VAULTS_SECRET` | 32+ character encryption key | `your-32-char-secret-key-here` | +| `AUTH_SECRET` | Authentication secret | `your-auth-secret` | + +> These values were detected from: [SOURCE FILES, e.g., src/libs/server-config/db.ts] + +--- + +## Optional Environment Variables + +These have defaults but can be customized. + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `3210` | Port the application listens on | +| `NODE_ENV` | `production` | Node environment | + +--- + +## How to Run + +### With Docker Compose (Recommended) + +```bash +# 1. Copy and edit the environment file +cp .env.docker.local .env.docker.local.real +# Edit .env.docker.local.real with your real values + +# 2. Start all services +docker-compose --env-file .env.docker.local.real up -d + +# 3. Check status +docker-compose ps +``` + +### With Docker Run (App Only) + +```bash +docker run -d \ + --name [PROJECT_NAME] \ + -p [PORT]:[PORT] \ + -e DATABASE_URL="postgres://user:pass@your-db-host:5432/myapp" \ + -e KEY_VAULTS_SECRET="your-32-char-secret-key-here-xxxxx" \ + -e AUTH_SECRET="your-auth-secret" \ + [IMAGE_NAME]:latest +``` + +### Build the Image + +```bash +docker buildx build --platform linux/amd64 --load -t [IMAGE_NAME]:latest . +``` + +--- + +## Exposed Ports + +| Port | Protocol | Description | +|------|----------|-------------| +| `[PORT]` | HTTP | Main application | + +--- + +## Health Check + +The Dockerfile includes an automatic health check. To verify manually: + +```bash +# Check container health status +docker inspect [PROJECT_NAME] --format='{{.State.Health.Status}}' + +# Test HTTP endpoint directly +curl -s -o /dev/null -w "%{http_code}" http://localhost:[PORT] +# Expected: 200, 302, or 401 +``` + +--- + +## Database Migrations + +[CONDITIONAL SECTION — only include if migration_system.detected == true] + +Migrations run automatically on startup via `docker-entrypoint.sh`. + +- **ORM**: [ORM_NAME] +- **Migration count**: [MIGRATION_COUNT] files +- **Migration directory**: [MIGRATION_DIR] +- **Execution timing**: runtime (before app starts) + +To verify migrations ran successfully: + +```bash +docker-compose exec postgres psql -U [DB_USER] -d [DB_NAME] -c "\dt" +# Expected: list of tables including users, sessions, etc. +``` + +[END CONDITIONAL — omit this entire section if no migration system detected] + +--- + +## Services (docker-compose) + +[CONDITIONAL SECTION — only include if docker-compose.yml was generated] + +| Service | Image | Purpose | +|---------|-------|---------| +| `app` | Built from Dockerfile | Main application | +| `postgres` | `pgvector/pgvector:pg16` | Database | + +[END CONDITIONAL — omit this entire section if no compose file generated] + +--- + +## Troubleshooting + +### App returns 500 error +```bash +docker-compose logs app | tail -50 +``` +Check for database connection errors or missing environment variables. + +### Migrations did not run +```bash +docker-compose exec postgres psql -U [DB_USER] -d [DB_NAME] -c "\dt" +``` +If no tables exist, check the entrypoint script logs: +```bash +docker-compose logs app | grep -i "migrat" +``` + +### Container exits immediately +```bash +docker-compose logs app +``` +Usually indicates a missing required environment variable. +```` + +**Template rules**: +- "Required" vs "Optional" env vars are pulled from `analysis.run.env_required` vs `analysis.run.env_optional` +- The Database Migrations section is **conditional** on `migration_system.detected == true` — omit entirely if false +- The Services table is **conditional** on `external_services` having at least one detected service — omit entirely if no compose file was generated +- All bracketed values `[LIKE_THIS]` must be filled by the agent from analysis results — do not use literal brackets in the output file +- The env var table rows are illustrative — use the actual detected variables for the project diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/scripts/validate-dockerfile.mjs b/plugins/labring/sealos-skills/skills/dockerfile-skill/scripts/validate-dockerfile.mjs new file mode 100644 index 00000000..65263f79 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/scripts/validate-dockerfile.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +/** + * Dockerfile Validation Script + * + * Checks generated Dockerfile for common issues before build. + * Exit code 0 = valid, exit code 1 = has errors. + * + * Usage: node validate-dockerfile.mjs [--port=3000] [--json] + */ + +import fs from 'fs'; +import path from 'path'; + +function validate(dockerfilePath, opts = {}) { + const content = fs.readFileSync(dockerfilePath, 'utf-8'); + const lines = content.split('\n'); + const issues = []; + + // 1. No :latest tags + for (let i = 0; i < lines.length; i++) { + if (/^FROM\s+\S+:latest/i.test(lines[i])) { + issues.push({ severity: 'error', line: i + 1, rule: 'no-latest', msg: `Using :latest tag: ${lines[i].trim()}` }); + } + } + + // 2. Has non-root USER + if (!/^USER\s+(?!root)/m.test(content)) { + issues.push({ severity: 'warn', rule: 'non-root-user', msg: 'No non-root USER instruction found' }); + } + + // 3. Multi-stage build (when build step likely needed) + const fromCount = (content.match(/^FROM\s/gm) || []).length; + if (fromCount < 2 && /RUN\s+.*\b(build|compile)\b/i.test(content)) { + issues.push({ severity: 'warn', rule: 'multi-stage', msg: 'Single-stage build detected with build step — consider multi-stage' }); + } + + // 4. COPY . before dependency install (cache busting) + const copyAllIdx = lines.findIndex(l => /^COPY\s+\.\s+\./.test(l)); + const installIdx = lines.findIndex(l => /npm ci|npm install|pnpm install|yarn install|pip install|go mod download/.test(l)); + if (copyAllIdx !== -1 && installIdx !== -1 && copyAllIdx < installIdx) { + issues.push({ severity: 'error', rule: 'copy-before-install', msg: 'COPY . . before dependency install breaks Docker cache' }); + } + + // 5. EXPOSE matches expected port + if (opts.port) { + const exposeMatch = content.match(/^EXPOSE\s+(\d+)/m); + if (exposeMatch && exposeMatch[1] !== String(opts.port)) { + issues.push({ severity: 'warn', rule: 'port-mismatch', msg: `EXPOSE ${exposeMatch[1]} doesn't match expected port ${opts.port}` }); + } + if (!exposeMatch) { + issues.push({ severity: 'warn', rule: 'no-expose', msg: 'No EXPOSE instruction found' }); + } + } + + // 6. -dev packages in runtime stage (after last FROM) + const lastFromIdx = lines.reduce((acc, l, i) => /^FROM\s/.test(l) ? i : acc, 0); + const runtimeSection = lines.slice(lastFromIdx).join('\n'); + const devPkgs = runtimeSection.match(/(lib\w+-dev|python3-dev|gcc|g\+\+|make|build-essential)/g); + if (devPkgs && fromCount > 1) { + issues.push({ severity: 'warn', rule: 'dev-in-runtime', msg: `Build-time packages in runtime stage: ${[...new Set(devPkgs)].join(', ')}` }); + } + + // 7. Has CMD or ENTRYPOINT + if (!/^(CMD|ENTRYPOINT)\s/m.test(content)) { + issues.push({ severity: 'error', rule: 'no-cmd', msg: 'No CMD or ENTRYPOINT instruction found' }); + } + + // 8. .dockerignore exists + const dir = path.dirname(dockerfilePath); + if (!fs.existsSync(path.join(dir, '.dockerignore'))) { + issues.push({ severity: 'warn', rule: 'no-dockerignore', msg: 'No .dockerignore file found' }); + } + + const errors = issues.filter(i => i.severity === 'error'); + const warnings = issues.filter(i => i.severity === 'warn'); + + return { + valid: errors.length === 0, + errors: errors.length, + warnings: warnings.length, + issues, + }; +} + +// ─── CLI ──────────────────────────────────────────────────── +const args = process.argv.slice(2); +const dockerfilePath = args.find(a => !a.startsWith('--')); +const portFlag = args.find(a => a.startsWith('--port=')); +const jsonFlag = args.includes('--json'); +const port = portFlag ? parseInt(portFlag.split('=')[1]) : null; + +if (dockerfilePath) { + const absPath = path.resolve(dockerfilePath); + if (!fs.existsSync(absPath)) { + console.error(`File not found: ${absPath}`); + process.exit(1); + } + const result = validate(absPath, { port }); + if (jsonFlag) { + console.log(JSON.stringify(result, null, 2)); + } else { + if (result.errors > 0) console.log(`✗ ${result.errors} error(s), ${result.warnings} warning(s)`); + else if (result.warnings > 0) console.log(`⚠ ${result.warnings} warning(s), no errors`); + else console.log('✓ Dockerfile validation passed'); + for (const i of result.issues) { + const icon = i.severity === 'error' ? '✗' : '⚠'; + const loc = i.line ? `:${i.line}` : ''; + console.log(` ${icon} [${i.rule}]${loc} ${i.msg}`); + } + } + process.exit(result.valid ? 0 : 1); +} + +export { validate }; diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-drizzle.sh b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-drizzle.sh new file mode 100644 index 00000000..adbb4615 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-drizzle.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -e + +echo "==> Running Drizzle migrations..." +export NODE_PATH=/app/node_modules:/deps/node_modules + +if [ -d "{{MIGRATION_DIR}}" ]; then + node -e " + const { drizzle } = require('drizzle-orm/node-postgres'); + const { migrate } = require('drizzle-orm/node-postgres/migrator'); + const { Pool } = require('pg'); + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + const db = drizzle(pool); + migrate(db, { migrationsFolder: '{{MIGRATION_DIR}}' }) + .then(() => { console.log('Migrations complete'); pool.end(); }) + .catch(e => { console.error('Migration failed:', e); process.exit(1); }); + " +fi + +echo "==> Starting application..." +exec node {{ENTRY_FILE}} diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-plain.sh b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-plain.sh new file mode 100644 index 00000000..997df35d --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-plain.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +echo "==> Starting application..." +exec node {{ENTRY_FILE}} diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-prisma.sh b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-prisma.sh new file mode 100644 index 00000000..7b8b8e4e --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-prisma.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "==> Running Prisma migrations..." +npx prisma migrate deploy + +echo "==> Starting application..." +exec node {{ENTRY_FILE}} diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-typeorm.sh b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-typeorm.sh new file mode 100644 index 00000000..fac32a33 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/assets/docker-entrypoint-typeorm.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "==> Running TypeORM migrations..." +npx typeorm migration:run -d {{DATASOURCE_PATH}} + +echo "==> Starting application..." +exec node {{ENTRY_FILE}} diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/golang.dockerfile b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/golang.dockerfile new file mode 100644 index 00000000..40b45582 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/golang.dockerfile @@ -0,0 +1,78 @@ +# syntax=docker/dockerfile:1.4 + +# ============================================ +# Stage 1: Build +# ============================================ +FROM golang:1.21.6-alpine AS build + +WORKDIR /app + +# Install git for private modules (if needed) +RUN apk add --no-cache git ca-certificates + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies with cache mount +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +# Copy source code +COPY . . + +# Build static binary +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main . + +# ============================================ +# Stage 2: Runtime +# ============================================ +FROM alpine:3.19 AS runtime + +WORKDIR /app + +# Install CA certificates for HTTPS +RUN apk --no-cache add ca-certificates tzdata + +# Set environment variables +ENV PORT=8080 +ENV GIN_MODE=release + +# Copy binary from build stage +COPY --from=build /app/main . + +# Copy static/config files if needed +# COPY --from=build /app/config ./config +# COPY --from=build /app/templates ./templates + +# Create non-root user +RUN adduser -D -u 1000 appuser +USER appuser + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/health || exit 1 + +# Start application +CMD ["./main"] + +# ============================================ +# Alternative: Scratch image (smallest possible) +# Use only for fully static binaries +# ============================================ +# FROM scratch AS runtime-scratch +# +# WORKDIR /app +# +# COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +# COPY --from=build /app/main . +# +# USER 1000:1000 +# +# EXPOSE 8080 +# +# CMD ["./main"] diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/java-springboot.dockerfile b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/java-springboot.dockerfile new file mode 100644 index 00000000..ea4c0d19 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/java-springboot.dockerfile @@ -0,0 +1,67 @@ +# syntax=docker/dockerfile:1.4 + +# ============================================ +# Stage 1: Build +# ============================================ +FROM eclipse-temurin:21-jdk-alpine AS build + +WORKDIR /app + +# Install Maven or Gradle (choose one) +# For Maven: +COPY mvnw pom.xml ./ +COPY .mvn .mvn +RUN chmod +x mvnw + +# Download dependencies with cache +RUN --mount=type=cache,target=/root/.m2 \ + ./mvnw dependency:go-offline -B + +# Copy source code +COPY src ./src + +# Build application +RUN --mount=type=cache,target=/root/.m2 \ + ./mvnw package -DskipTests -B + +# For Gradle (alternative): +# COPY gradlew build.gradle settings.gradle ./ +# COPY gradle ./gradle +# RUN chmod +x gradlew +# RUN --mount=type=cache,target=/root/.gradle \ +# ./gradlew dependencies --no-daemon +# COPY src ./src +# RUN --mount=type=cache,target=/root/.gradle \ +# ./gradlew build -x test --no-daemon + +# ============================================ +# Stage 2: Runtime +# ============================================ +FROM eclipse-temurin:21-jre-alpine AS runtime + +WORKDIR /app + +# Set environment variables +ENV JAVA_OPTS="-Xmx512m -Xms256m" +ENV SERVER_PORT=8080 +ENV SPRING_PROFILES_ACTIVE=production + +# Install fonts if needed for PDF generation +# RUN apk add --no-cache fontconfig fonts-dejavu + +# Copy JAR from build stage +COPY --from=build /app/target/*.jar app.jar + +# Create non-root user +RUN adduser -D -u 1000 appuser +USER appuser + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/actuator/health || exit 1 + +# Start application +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-express.dockerfile b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-express.dockerfile new file mode 100644 index 00000000..1e68f474 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-express.dockerfile @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1.4 + +# ============================================ +# Stage 1: Dependencies +# ============================================ +FROM node:20.11.1-slim AS deps + +WORKDIR /app + +# Install system dependencies for native modules (if needed) +# {{SYSTEM_DEPS}} + +# Copy package files +COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./ + +# Install dependencies with cache mount +RUN --mount=type=cache,target=/root/.npm \ + npm ci --only=production + +# ============================================ +# Stage 2: Build (if needed) +# ============================================ +FROM deps AS build + +# Install all dependencies including devDependencies +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +# Copy source code +COPY . . + +# Build application +RUN npm run build + +# ============================================ +# Stage 3: Runtime +# ============================================ +FROM node:20.11.1-slim AS runtime + +WORKDIR /app + +# Set production environment +ENV NODE_ENV=production +ENV PORT=3000 + +# Copy production dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules + +# Copy built application from build stage +COPY --from=build /app/dist ./dist +# Or if no build step: +# COPY . . + +# Copy package.json for npm start +COPY package.json ./ + +# Use non-root user +USER node + +# Expose port +EXPOSE 3000 + +# Health check without curl +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD node -e "require('http').get('http://127.0.0.1:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" + +# Start application +CMD ["node", "dist/index.js"] diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-nextjs-bun.dockerfile b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-nextjs-bun.dockerfile new file mode 100644 index 00000000..7979274b --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-nextjs-bun.dockerfile @@ -0,0 +1,107 @@ +# syntax=docker/dockerfile:1 +# +# Next.js + Bun Dockerfile Template +# +# Features: +# - Multi-stage build for minimal image size +# - Bun for fast dependency installation and build +# - Node.js slim runtime (Bun runtime is larger) +# - Standalone output support (recommended) +# - Non-root user for security +# +# Usage: +# - Ensure next.config has `output: 'standalone'` +# - Replace {BUN_VERSION} with specific version (e.g., 1.1.42) +# - Replace {NODE_VERSION} with specific version (e.g., 20.18.1) +# - Add build-time env var placeholders as needed (see comments) + +# ============================================ +# Stage 1: Dependencies +# ============================================ +FROM oven/bun:{BUN_VERSION}-slim AS deps + +WORKDIR /app + +# Copy dependency files +COPY package.json bun.lockb ./ + +# Install dependencies +# Note: --frozen-lockfile ensures reproducible builds +RUN bun install --frozen-lockfile + +# ============================================ +# Stage 2: Build +# ============================================ +FROM oven/bun:{BUN_VERSION}-slim AS builder + +WORKDIR /app + +# Copy dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules + +# Copy source code +COPY . . + +# Disable telemetry during build +ENV NEXT_TELEMETRY_DISABLED=1 + +# ============================================ +# Build-time Environment Variables +# ============================================ +# Next.js statically analyzes API routes during build. +# If your code initializes SDKs at module top-level, add placeholders here. +# These are NOT used at runtime - actual values come from docker run -e +# +# Common examples (uncomment as needed): +# ARG RESEND_API_KEY=re_placeholder_key +# ARG STRIPE_SECRET_KEY=sk_placeholder_key +# ARG NOTION_SECRET=placeholder_notion_secret +# ARG NOTION_DB=placeholder_notion_db +# ARG UPSTASH_REDIS_REST_URL=https://placeholder.upstash.io +# ARG UPSTASH_REDIS_REST_TOKEN=placeholder_token +# ARG DATABASE_URL=postgres://placeholder:placeholder@localhost:5432/placeholder +# +# ENV RESEND_API_KEY=${RESEND_API_KEY} +# ENV STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} +# ENV NOTION_SECRET=${NOTION_SECRET} +# ENV NOTION_DB=${NOTION_DB} +# ENV UPSTASH_REDIS_REST_URL=${UPSTASH_REDIS_REST_URL} +# ENV UPSTASH_REDIS_REST_TOKEN=${UPSTASH_REDIS_REST_TOKEN} +# ENV DATABASE_URL=${DATABASE_URL} + +# Build the application +RUN bun run build + +# ============================================ +# Stage 3: Production Runtime +# ============================================ +# Using Node.js slim for runtime (smaller than Bun runtime image) +FROM node:{NODE_VERSION}-slim AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Create non-root user for security +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy standalone build output (much smaller than full node_modules) +# Requires `output: 'standalone'` in next.config +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Health check without installing curl +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://127.0.0.1:3000', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" + +CMD ["node", "server.js"] diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-nextjs.dockerfile b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-nextjs.dockerfile new file mode 100644 index 00000000..38f1d16a --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-nextjs.dockerfile @@ -0,0 +1,168 @@ +# syntax=docker/dockerfile:1.4 + +# ============================================ +# Next.js Production Dockerfile +# Supports: standalone mode, workspace/monorepo, custom entry points +# ============================================ + +# ============================================ +# Stage 1: Base - Common setup +# ============================================ +FROM node:{{NODE_VERSION}}-slim AS base + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + {{SYSTEM_DEPS}} \ + && rm -rf /var/lib/apt/lists/* + +# Enable corepack for pnpm (if using pnpm) +# {{PNPM_SETUP}} +# RUN corepack enable && corepack prepare pnpm@{{PNPM_VERSION}} --activate + +# ============================================ +# Stage 2: Dependencies +# ============================================ +FROM base AS deps + +WORKDIR /app + +# Copy package manager files +COPY package.json {{PACKAGE_MANAGER_FILES}} ./ + +# For workspace/monorepo: Copy all workspace package.json files +# {{WORKSPACE_COPY}} +# COPY packages ./packages +# COPY patches ./patches +# COPY e2e/package.json ./e2e/ +# COPY apps/desktop/src/main/package.json ./apps/desktop/src/main/ + +# Install dependencies +# If lockfile=false in .npmrc, do NOT use --frozen-lockfile +RUN --mount=type=cache,target=/root/.npm \ + {{INSTALL_COMMAND}} + +# ============================================ +# Stage 3: Build +# ============================================ +FROM base AS build + +WORKDIR /app + +# Copy dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules +# For workspace: also copy package directories with their node_modules +# {{WORKSPACE_DEPS_COPY}} +# COPY --from=deps /app/packages ./packages + +# Copy source code +COPY . . + +# Set build environment +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_OPTIONS="--max-old-space-size=8192" + +# Build-time environment variables (placeholders for SSG/ISR) +# These are replaced at runtime with actual values +# {{BUILD_TIME_ENV}} +# ARG KEY_VAULTS_SECRET_PLACEHOLDER="build-placeholder-32chars" +# ARG DATABASE_URL_PLACEHOLDER="postgres://placeholder:placeholder@localhost:5432/placeholder" +# ENV KEY_VAULTS_SECRET=${KEY_VAULTS_SECRET_PLACEHOLDER} +# ENV DATABASE_URL=${DATABASE_URL_PLACEHOLDER} +# ENV AUTH_SECRET=${KEY_VAULTS_SECRET_PLACEHOLDER} +# ENV DATABASE_DRIVER="" + +# Build the application +RUN {{BUILD_COMMAND}} + +# ============================================ +# Stage 4: Production Runtime +# ============================================ +FROM node:{{NODE_VERSION}}-slim AS production + +# Build arguments for labels +ARG SHA + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + {{RUNTIME_DEPS}} \ + && rm -rf /var/lib/apt/lists/* \ + && ln -sf /usr/local/bin/node /bin/node + +# Create non-root user +RUN groupadd --gid 1001 nodejs \ + && useradd --uid 1001 --gid nodejs --shell /bin/bash --create-home nextjs + +WORKDIR /app + +# ============================================ +# Option A: Standalone mode (recommended) +# Requires: output: 'standalone' in next.config.js +# ============================================ +COPY --from=build --chown=nextjs:nodejs /app/public ./public +COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static + +# ============================================ +# Option B: Custom entry point (for complex apps) +# ============================================ +# {{CUSTOM_ENTRY_POINT}} +# COPY --from=build --chown=nextjs:nodejs /app/scripts/serverLauncher/startServer.js ./startServer.js +# COPY --from=build --chown=nextjs:nodejs /app/scripts/_shared ./scripts/_shared + +# ============================================ +# Option C: Database migrations (if needed) +# ============================================ +# {{MIGRATIONS}} +# COPY --from=build --chown=nextjs:nodejs /app/scripts/migrateServerDB/docker.cjs ./docker.cjs +# COPY --from=build --chown=nextjs:nodejs /app/scripts/migrateServerDB/errorHint.js ./errorHint.js +# COPY --from=build --chown=nextjs:nodejs /app/packages/database/migrations ./migrations + +# Set production environment +ENV NODE_ENV=production +ENV HOSTNAME="0.0.0.0" +ENV PORT={{PORT}} +ENV NEXT_TELEMETRY_DISABLED=1 + +# Labels +LABEL org.opencontainers.image.title="{{APP_NAME}}" +LABEL org.opencontainers.image.source="{{REPO_URL}}" +LABEL org.opencontainers.image.revision="${SHA}" + +# Switch to non-root user +USER nextjs + +# Expose port +EXPOSE {{PORT}} + +# Health check (adjust path as needed) +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD node -e "fetch('http://localhost:{{PORT}}/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +# Start the application +# Option A: Standard Next.js standalone +CMD ["node", "server.js"] + +# Option B: Custom entry point +# CMD ["node", "startServer.js"] + +# ============================================ +# Template Variables Reference +# ============================================ +# {{NODE_VERSION}} - e.g., "20.11.1" or "24" +# {{PNPM_VERSION}} - e.g., "10.20.0" +# {{PACKAGE_MANAGER_FILES}} - e.g., "pnpm-workspace.yaml .npmrc" +# {{INSTALL_COMMAND}} - e.g., "pnpm install --ignore-scripts" +# {{BUILD_COMMAND}} - e.g., "npm run build:docker" +# {{PORT}} - e.g., "3210" +# {{APP_NAME}} - e.g., "LobeChat" +# {{REPO_URL}} - e.g., "https://github.com/lobehub/lobe-chat" +# {{SYSTEM_DEPS}} - e.g., "proxychains4" +# {{RUNTIME_DEPS}} - e.g., "proxychains4" +# {{WORKSPACE_COPY}} - Workspace package.json COPY commands +# {{WORKSPACE_DEPS_COPY}} - Workspace with node_modules COPY commands +# {{BUILD_TIME_ENV}} - ARG/ENV for build-time variables +# {{CUSTOM_ENTRY_POINT}} - Custom server entry point COPY commands +# {{MIGRATIONS}} - Database migration file COPY commands diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-nuxt.dockerfile b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-nuxt.dockerfile new file mode 100644 index 00000000..21b36ea7 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/nodejs-nuxt.dockerfile @@ -0,0 +1,65 @@ +# syntax=docker/dockerfile:1.4 + +# ============================================ +# Stage 1: Dependencies +# ============================================ +FROM node:20.11.1-slim AS deps + +WORKDIR /app + +# Install system dependencies for native modules (if needed) +# {{SYSTEM_DEPS}} + +# Copy package files +COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./ + +# Install dependencies +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +# ============================================ +# Stage 2: Build +# ============================================ +FROM deps AS build + +WORKDIR /app + +# Copy source code +COPY . . + +# Set build-time environment variables +ARG NUXT_PUBLIC_API_BASE=http://localhost:3000 +ENV NUXT_PUBLIC_API_BASE=$NUXT_PUBLIC_API_BASE + +# Build Nuxt application +RUN npm run build + +# ============================================ +# Stage 3: Runtime +# ============================================ +FROM node:20.11.1-slim AS runtime + +WORKDIR /app + +# Set production environment +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOST=0.0.0.0 +ENV NITRO_PORT=3000 +ENV NITRO_HOST=0.0.0.0 + +# Copy built application (.output directory from Nuxt 3) +COPY --from=build /app/.output ./.output + +# Use non-root user +USER node + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD node -e "require('http').get('http://127.0.0.1:3000/api/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" + +# Start Nuxt server +CMD ["node", ".output/server/index.mjs"] diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/python-django.dockerfile b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/python-django.dockerfile new file mode 100644 index 00000000..fa2930ab --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/python-django.dockerfile @@ -0,0 +1,69 @@ +# syntax=docker/dockerfile:1.4 + +# ============================================ +# Stage 1: Build +# ============================================ +FROM python:3.11.7-slim AS build + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Create virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy requirements +COPY requirements.txt . + +# Install dependencies with cache mount +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --no-cache-dir -r requirements.txt + +# ============================================ +# Stage 2: Runtime +# ============================================ +FROM python:3.11.7-slim AS runtime + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PATH="/opt/venv/bin:$PATH" +ENV PORT=8000 +ENV DJANGO_SETTINGS_MODULE=config.settings.production + +# Copy virtual environment from build stage +COPY --from=build /opt/venv /opt/venv + +# Copy application code +COPY . . + +# Create non-root user +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app + +# Collect static files (run as root before switching user) +RUN python manage.py collectstatic --noinput + +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/')" + +# Start application with gunicorn +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "config.wsgi:application"] diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/python-fastapi.dockerfile b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/python-fastapi.dockerfile new file mode 100644 index 00000000..36ed0029 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/python-fastapi.dockerfile @@ -0,0 +1,61 @@ +# syntax=docker/dockerfile:1.4 + +# ============================================ +# Stage 1: Build (for compiled dependencies) +# ============================================ +FROM python:3.11.7-slim AS build + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Create virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy requirements +COPY requirements.txt . + +# Install dependencies with cache mount +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --no-cache-dir -r requirements.txt + +# ============================================ +# Stage 2: Runtime +# ============================================ +FROM python:3.11.7-slim AS runtime + +WORKDIR /app + +# Install runtime system dependencies (if needed) +# {{SYSTEM_DEPS}} + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PATH="/opt/venv/bin:$PATH" +ENV PORT=8000 + +# Copy virtual environment from build stage +COPY --from=build /opt/venv /opt/venv + +# Copy application code +COPY . . + +# Create non-root user +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')" + +# Start application with uvicorn +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/rust.dockerfile b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/rust.dockerfile new file mode 100644 index 00000000..b4051a5c --- /dev/null +++ b/plugins/labring/sealos-skills/skills/dockerfile-skill/templates/rust.dockerfile @@ -0,0 +1,88 @@ +# syntax=docker/dockerfile:1.4 + +# ============================================ +# Stage 1: Build +# ============================================ +FROM rust:1.75-slim AS builder + +WORKDIR /app + +# Install build essentials +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create a dummy project to cache dependencies +RUN cargo new --bin app +WORKDIR /app/app + +# Copy dependency manifests only +COPY Cargo.toml Cargo.lock ./ + +# Build dependencies only (cached unless Cargo.toml/Cargo.lock change) +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/app/app/target \ + cargo build --release && rm -rf src + +# Copy real source code +COPY src ./src + +# Build the actual application +# Touch main.rs to invalidate the dummy binary but keep dependency cache +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/app/app/target \ + touch src/main.rs && \ + cargo build --release && \ + cp target/release/app /usr/local/bin/app + +# ============================================ +# Stage 2: Runtime +# ============================================ +FROM debian:bookworm-slim AS runtime + +WORKDIR /app + +# Install runtime dependencies (TLS + timezone) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates tzdata \ + && rm -rf /var/lib/apt/lists/* + +# Set environment variables +ENV PORT=8080 + +# Copy binary from builder +COPY --from=builder /usr/local/bin/app . + +# Create non-root user +RUN useradd -r -u 1001 -s /bin/false appuser +USER 1001 + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://127.0.0.1:8080/health || exit 1 + +# Start application +CMD ["./app"] + +# ============================================ +# Alternative: Alpine + musl static build +# Smaller image (~30MB) but requires musl target +# ============================================ +# FROM rust:1.75-alpine AS builder +# +# RUN apk add --no-cache musl-dev pkgconfig openssl-dev +# WORKDIR /app +# COPY Cargo.toml Cargo.lock ./ +# RUN cargo new --bin app && cd app && cp ../Cargo.toml ../Cargo.lock . && cargo build --release && rm -rf src +# COPY src app/src +# RUN cd app && touch src/main.rs && cargo build --release +# +# FROM alpine:3.19 +# RUN apk --no-cache add ca-certificates +# COPY --from=builder /app/app/target/release/app /usr/local/bin/app +# USER 1000 +# EXPOSE 8080 +# CMD ["app"] diff --git a/plugins/labring/sealos-skills/skills/sealos-app-builder/SKILL.md b/plugins/labring/sealos-skills/skills/sealos-app-builder/SKILL.md new file mode 100644 index 00000000..0384c1ef --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-app-builder/SKILL.md @@ -0,0 +1,160 @@ +--- +name: sealos-app-builder +description: Build, adapt, and document apps that run inside Sealos Desktop using the Sealos app SDK. Use when creating a new Sealos app, integrating an existing web app into Sealos Desktop, wiring Sealos session data into business features, preparing local iframe-based debugging, or producing beginner-friendly Sealos app tutorials and starter implementations. Also triggers on "/sealos-app-builder". +--- + +# Sealos App Builder + +## Overview + +Use this skill to turn a generic web app into a Sealos app that runs inside Sealos Desktop, or to scaffold a new Sealos app from scratch. Focus on the repeatable parts: SDK initialization, session access, language sync, business-data integration, local debugging through a Desktop test app, and publish readiness. + +Prefer a simple, teachable implementation that a beginner can understand and extend. + +## Core Workflow + +### 1. Identify the starting point + +Classify the request into one of these paths: + +1. Create a new Sealos app from scratch. +2. Adapt an existing web app to run inside Sealos Desktop. +3. Add Sealos identity and business-data integration to an app that already renders. +4. Produce documentation or a tutorial instead of code changes. + +If the repository already contains Sealos-related code, inspect local sources first. In particular: + +1. Look for `packages/client-sdk` or equivalent SDK sources. +2. Look for existing provider apps under `providers/` or similar directories. +3. Reuse the repository's established framework and routing patterns when they are already in place. + +If the repository does not contain local Sealos sources, use the bundled references in this skill as the baseline. + +### 2. Integrate the Sealos app SDK + +Treat Sealos Desktop integration as a root-level concern. + +Before using any starter template, install the SDK first: + +```bash +pnpm add @labring/sealos-desktop-sdk +``` + +Use `npm install @labring/sealos-desktop-sdk` or `yarn add @labring/sealos-desktop-sdk` when the project uses a different package manager. + +1. Initialize the SDK once in a client-only root component. +2. Fetch `getSession()` and `getLanguage()` early. +3. Store session, language, loading state, and desktop availability in a shared context or store. +4. Listen for language changes through `EVENT_NAME.CHANGE_I18N` when the app needs runtime language sync. +5. Add a graceful fallback when the app is opened outside Sealos Desktop. + +Read [references/minimal-app-template.md](references/minimal-app-template.md) before implementing the root integration. +If the app uses Next.js App Router, also read [references/nextjs-app-router.md](references/nextjs-app-router.md). + +Use one of these starter templates: + +1. [assets/templates/react/sealos-provider.tsx](assets/templates/react/sealos-provider.tsx) for React. +2. [assets/templates/vue/use-sealos.ts](assets/templates/vue/use-sealos.ts) for Vue. + +### 3. Connect Sealos identity to business data + +For most apps, the key integration is not the iframe itself but the user mapping. + +1. Use `session.user.id` as the stable app-level user identifier. +2. Persist display-friendly fields such as `name`, `avatar`, `k8sUsername`, and `nsid` when useful. +3. Keep business data in the app's own database and API routes. +4. Model Sealos user identity as input to your business logic, not as your entire backend. + +Read [references/data-integration-patterns.md](references/data-integration-patterns.md) when you need schema or API guidance. + +### 4. Prepare local debugging in the real runtime + +Do not assume a successful browser render means Sealos integration works. + +The app usually needs to be opened by Sealos Desktop in an iframe for SDK calls like `getSession()` to succeed. When local debugging is part of the task, read [references/local-debug-and-test-app.md](references/local-debug-and-test-app.md). + +Use these rules: + +1. Explain clearly when a page is outside Sealos Desktop. +2. Prefer a test app inside Sealos Desktop for end-to-end verification. +3. Avoid server-side SDK calls. + +### 5. Prepare for publishing + +When the user wants deployment or launch readiness: + +1. Verify environment variables. +2. Verify database connectivity and migrations. +3. Confirm the app works when launched from Sealos Desktop. +4. Confirm any cross-app navigation or event usage is valid. +5. Summarize the remaining manual registration or platform configuration steps. + +Use [references/publish-checklist.md](references/publish-checklist.md) as the release checklist. + +## Implementation Rules + +### Keep the integration simple + +Default to the smallest viable Sealos integration: + +1. One root provider or store. +2. One business identity mapping pattern. +3. One fallback path for non-Desktop access. + +Avoid spreading SDK initialization across multiple pages or components. + +### Prefer the repository's real SDK surface + +If the current workspace contains actual Sealos SDK sources or existing Sealos apps: + +1. Inspect those sources. +2. Follow the real exported APIs and types. +3. Call out repository-specific differences from generic examples. + +### Use the official SDK package name + +Use `@labring/sealos-desktop-sdk` in generated examples and starter code by default. + +Only deviate from that if the target repository already has an established local workspace alias and the user explicitly wants to preserve it. + +## Decision Guide + +### If the user asks for "How do I build a Sealos app?" + +Provide: + +1. A short explanation of the runtime model. +2. A minimal SDK integration example. +3. A business-data mapping example. +4. Local debugging guidance through a Sealos Desktop test app. + +### If the user asks to modify an existing app + +Do this order: + +1. Inspect the current app entry point. +2. Add or refactor a single root Sealos provider. +3. Wire business APIs to `session.user.id`. +4. Verify fallback behavior outside Desktop. + +### If the user asks for documentation or a tutorial + +Structure the output around: + +1. What a Sealos app is. +2. How to initialize the SDK. +3. How to obtain and use the session. +4. How to integrate business data. +5. How to debug through a Desktop test app. +6. How to publish and verify. + +## References + +Read only the files needed for the task: + +1. [references/sdk-capabilities.md](references/sdk-capabilities.md) for available SDK APIs and runtime behavior. +2. [references/minimal-app-template.md](references/minimal-app-template.md) for the recommended root integration pattern. +3. [references/nextjs-app-router.md](references/nextjs-app-router.md) for a concrete Next.js App Router placement example. +4. [references/data-integration-patterns.md](references/data-integration-patterns.md) for user mapping, database schemas, and API shapes. +5. [references/local-debug-and-test-app.md](references/local-debug-and-test-app.md) for iframe-based debugging and Desktop test app setup. +6. [references/publish-checklist.md](references/publish-checklist.md) for launch-readiness steps. diff --git a/plugins/labring/sealos-skills/skills/sealos-app-builder/assets/templates/react/sealos-provider.tsx b/plugins/labring/sealos-skills/skills/sealos-app-builder/assets/templates/react/sealos-provider.tsx new file mode 100644 index 00000000..659388c6 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-app-builder/assets/templates/react/sealos-provider.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { EVENT_NAME, type SessionV1 } from '@labring/sealos-desktop-sdk'; +import { createSealosApp, sealosApp } from '@labring/sealos-desktop-sdk/app'; + +type SealosContextValue = { + session: SessionV1 | null; + language: string; + loading: boolean; + error: string | null; + isInSealosDesktop: boolean; +}; + +const SealosContext = createContext({ + session: null, + language: 'en', + loading: true, + error: null, + isInSealosDesktop: false +}); + +export function useSealos() { + return useContext(SealosContext); +} + +export function SealosProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [language, setLanguage] = useState('en'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isInSealosDesktop, setIsInSealosDesktop] = useState(false); + + useEffect(() => { + const cleanupApp = createSealosApp(); + let cleanupEvent: (() => void) | undefined; + let mounted = true; + + const bootstrap = async () => { + try { + const [nextSession, nextLanguage] = await Promise.all([ + sealosApp.getSession(), + sealosApp.getLanguage() + ]); + + if (!mounted) return; + + setSession(nextSession); + setLanguage(nextLanguage.lng || 'en'); + setIsInSealosDesktop(true); + setLoading(false); + + cleanupEvent = sealosApp.addAppEventListen( + EVENT_NAME.CHANGE_I18N, + (data: { currentLanguage?: string }) => { + setLanguage(data.currentLanguage || 'en'); + } + ); + } catch { + if (!mounted) return; + + setError('This page is not running inside Sealos Desktop.'); + setIsInSealosDesktop(false); + setLoading(false); + } + }; + + bootstrap(); + + return () => { + mounted = false; + cleanupEvent?.(); + cleanupApp?.(); + }; + }, []); + + return ( + + {children} + + ); +} diff --git a/plugins/labring/sealos-skills/skills/sealos-app-builder/assets/templates/vue/use-sealos.ts b/plugins/labring/sealos-skills/skills/sealos-app-builder/assets/templates/vue/use-sealos.ts new file mode 100644 index 00000000..de5f09e4 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-app-builder/assets/templates/vue/use-sealos.ts @@ -0,0 +1,64 @@ +import { onMounted, onUnmounted, readonly, ref } from 'vue'; +import { EVENT_NAME, type SessionV1 } from '@labring/sealos-desktop-sdk'; +import { createSealosApp, sealosApp } from '@labring/sealos-desktop-sdk/app'; + +const session = ref(null); +const language = ref('en'); +const loading = ref(true); +const error = ref(null); +const isInSealosDesktop = ref(false); + +let cleanupApp: (() => void) | undefined; +let cleanupEvent: (() => void) | undefined; +let initialized = false; + +async function bootstrap() { + try { + const [nextSession, nextLanguage] = await Promise.all([ + sealosApp.getSession(), + sealosApp.getLanguage() + ]); + + session.value = nextSession; + language.value = nextLanguage.lng || 'en'; + isInSealosDesktop.value = true; + loading.value = false; + + cleanupEvent = sealosApp.addAppEventListen( + EVENT_NAME.CHANGE_I18N, + (data: { currentLanguage?: string }) => { + language.value = data.currentLanguage || 'en'; + } + ); + } catch { + error.value = 'This page is not running inside Sealos Desktop.'; + isInSealosDesktop.value = false; + loading.value = false; + } +} + +export function useSealos() { + onMounted(() => { + if (initialized) return; + + cleanupApp = createSealosApp(); + initialized = true; + bootstrap(); + }); + + onUnmounted(() => { + cleanupEvent?.(); + cleanupApp?.(); + cleanupEvent = undefined; + cleanupApp = undefined; + initialized = false; + }); + + return { + session: readonly(session), + language: readonly(language), + loading: readonly(loading), + error: readonly(error), + isInSealosDesktop: readonly(isInSealosDesktop) + }; +} diff --git a/plugins/labring/sealos-skills/skills/sealos-app-builder/references/data-integration-patterns.md b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/data-integration-patterns.md new file mode 100644 index 00000000..e86c8ce4 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/data-integration-patterns.md @@ -0,0 +1,74 @@ +# Data Integration Patterns + +Use this reference when wiring Sealos identity into an app's own business data. + +## Core principle + +Sealos provides runtime identity. Your app still owns its own database, business rules, and APIs. + +For most apps: + +1. Use `session.user.id` as the stable application user key. +2. Store display fields such as `name` and `avatar` as convenient denormalized data. +3. Keep business records in your own tables. + +## Recommended user mapping + +Persist a user record that mirrors the most useful Sealos fields: + +1. `id` +2. `name` +3. `avatar` +4. `k8sUsername` +5. `nsid` + +This makes later joins, ownership checks, and display logic simpler. + +## Common schema patterns + +### Pattern A: App users + records + +Use when the app has reusable user profiles plus separate business entities. + +Example: + +1. `users` +2. `posts` +3. `votes` +4. `projects` +5. `records` + +### Pattern B: Single-purpose table + +Use when the app is simple and only needs one business table keyed by user. + +Good for: + +1. surveys +2. feature flags +3. profile preferences +4. user-scoped settings + +## API pattern + +Typical write flow: + +1. Read `session.user` in the client. +2. Send the relevant identity fields plus the business payload to your API route. +3. Upsert the user row. +4. Insert or upsert the business row. + +Typical read flow: + +1. Query business data from the server. +2. Order and limit for the UI. +3. Return a display-ready response. + +When creating starter code, prefer a business-neutral route shape over a demo-specific endpoint. + +## Guardrails + +1. Do not hardcode database connection strings. +2. Do not make the whole backend depend on raw Sealos session objects. +3. Keep Sealos fields narrow and intentional. +4. If authentication requirements become stricter, move validation into the server later without changing the core data model. diff --git a/plugins/labring/sealos-skills/skills/sealos-app-builder/references/local-debug-and-test-app.md b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/local-debug-and-test-app.md new file mode 100644 index 00000000..a133e932 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/local-debug-and-test-app.md @@ -0,0 +1,47 @@ +# Local Debug and Test App + +Use this reference when SDK calls work inconsistently during development. + +## Why direct browser access is misleading + +A Sealos app can render normally in a browser tab and still fail to integrate with Desktop. + +That usually happens because: + +1. the app is not inside the Sealos Desktop iframe +2. Desktop is not present to answer SDK requests +3. calls such as `getSession()` or `getLanguage()` time out or reject + +## Local debugging checklist + +1. Start the app locally. +2. Confirm the root Sealos provider initializes only in the browser. +3. Open the app through a Sealos Desktop test app, not only through the raw URL. +4. Verify `getSession()` succeeds inside Desktop. +5. Verify the standalone fallback is readable outside Desktop. + +## Test app guidance + +For end-to-end Sealos debugging, create a test app in Sealos Desktop that points to the local or preview URL of your app. + +Use it to verify: + +1. session retrieval +2. language retrieval +3. runtime language change handling +4. business API writes tied to the Sealos user +5. cross-app event behavior, if any + +## Failure patterns + +### `getSession()` fails outside Desktop + +This is expected. Show a user-facing fallback instead of treating it as a mysterious bug. + +### Session works only after reload + +Check whether the SDK is being initialized more than once or from multiple entry points. + +### Event listeners behave strangely + +Look for duplicate `createSealosApp()` calls or repeated event subscriptions without cleanup. diff --git a/plugins/labring/sealos-skills/skills/sealos-app-builder/references/minimal-app-template.md b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/minimal-app-template.md new file mode 100644 index 00000000..5abab0c0 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/minimal-app-template.md @@ -0,0 +1,85 @@ +# Minimal App Template + +Use this reference when a Sealos app needs the smallest stable integration pattern. + +## Recommended shape + +Put the Sealos integration in one client-only root provider. + +## Install first + +Install the official SDK package before copying any template code: + +```bash +pnpm add @labring/sealos-desktop-sdk +``` + +Equivalent commands: + +```bash +npm install @labring/sealos-desktop-sdk +yarn add @labring/sealos-desktop-sdk +``` + +The provider should own: + +1. SDK initialization. +2. Session loading. +3. Language loading. +4. Desktop availability state. +5. Optional language-change subscription. + +## Recommended state shape + +```ts +type SealosContextValue = { + session: SessionV1 | null; + language: string; + loading: boolean; + error: string | null; + isInSealosDesktop: boolean; +}; +``` + +## Root integration rules + +1. Call `createSealosApp()` once. +2. Fetch `getSession()` and `getLanguage()` early. +3. Store the results centrally. +4. Clean up listeners on unmount. +5. Show a clear fallback if Desktop is unavailable. + +## Best practices + +### Use a single provider + +Prefer one provider or store near the app root over repeated per-page initialization. + +### Make the fallback explicit + +If the app is opened outside Sealos Desktop, set: + +1. `isInSealosDesktop = false` +2. `loading = false` +3. a friendly error or info message + +### Keep the business layer unaware of iframe details + +Page-level components should consume: + +1. `session` +2. `language` +3. `isInSealosDesktop` + +They should not care about raw `postMessage` plumbing. + +## Starter file + +Use one of these starter files: + +1. [../assets/templates/react/sealos-provider.tsx](../assets/templates/react/sealos-provider.tsx) for React. +2. [../assets/templates/vue/use-sealos.ts](../assets/templates/vue/use-sealos.ts) for Vue. + +## Next.js App Router + +If the app uses Next.js App Router, read [nextjs-app-router.md](nextjs-app-router.md) and keep the provider inside a client component that is mounted from the root layout. diff --git a/plugins/labring/sealos-skills/skills/sealos-app-builder/references/nextjs-app-router.md b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/nextjs-app-router.md new file mode 100644 index 00000000..e9aae1f3 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/nextjs-app-router.md @@ -0,0 +1,65 @@ +# Next.js App Router + +Use this reference when the target app uses Next.js App Router and you need a concrete placement example for the Sealos integration. + +## Recommended structure + +Use a small client-only wrapper for providers, then mount it from `app/layout.tsx`. + +Example layout: + +```text +app/ + layout.tsx + providers.tsx +components/ + sealos-provider.tsx +``` + +## Why this structure works + +1. `app/layout.tsx` can remain a server component. +2. `app/providers.tsx` becomes the client boundary for SDK initialization. +3. `components/sealos-provider.tsx` contains the reusable Sealos context logic. + +## Minimal example + +### `components/sealos-provider.tsx` + +Use [../assets/templates/react/sealos-provider.tsx](../assets/templates/react/sealos-provider.tsx). + +### `app/providers.tsx` + +```tsx +'use client'; + +import type { ReactNode } from 'react'; +import { SealosProvider } from '@/components/sealos-provider'; + +export function Providers({ children }: { children: ReactNode }) { + return {children}; +} +``` + +### `app/layout.tsx` + +```tsx +import type { ReactNode } from 'react'; +import { Providers } from './providers'; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} +``` + +## Rules + +1. Do not call `createSealosApp()` directly from `app/layout.tsx` if it is a server component. +2. Keep SDK initialization in a client component such as `providers.tsx` or the provider itself. +3. Expose `session`, `language`, and `isInSealosDesktop` through context or a store so route segments stay simple. diff --git a/plugins/labring/sealos-skills/skills/sealos-app-builder/references/publish-checklist.md b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/publish-checklist.md new file mode 100644 index 00000000..044b094a --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/publish-checklist.md @@ -0,0 +1,34 @@ +# Publish Checklist + +Use this reference when preparing a Sealos app for handoff, preview, or production launch. + +## Functional checks + +1. The app loads correctly when opened by Sealos Desktop. +2. The app handles direct browser access gracefully. +3. `getSession()` succeeds inside Desktop. +4. Business data writes are tied to the correct Sealos user. +5. Business data reads render correctly for real records. + +## Configuration checks + +1. Environment variables are documented and present. +2. Database migrations or schema setup are complete. +3. The package name and SDK imports match the target workspace. +4. Any required Desktop-side event names are confirmed. + +## Release checks + +1. The app has a stable URL or deployment target. +2. A Sealos Desktop test app is already verified against that URL. +3. Manual registration or platform configuration steps are written down. +4. Beginner-facing docs explain the Desktop runtime requirement. + +## Recommended final summary + +When handing off the work, summarize: + +1. what was implemented +2. how to run it +3. how to validate it inside Sealos Desktop +4. which steps remain manual diff --git a/plugins/labring/sealos-skills/skills/sealos-app-builder/references/sdk-capabilities.md b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/sdk-capabilities.md new file mode 100644 index 00000000..fd9c3fc0 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-app-builder/references/sdk-capabilities.md @@ -0,0 +1,103 @@ +# SDK Capabilities + +Use this reference when you need a concise explanation of the app-side Sealos SDK surface. + +## Runtime model + +A Sealos app is typically a web app loaded inside Sealos Desktop through an iframe. The app SDK communicates with Desktop through `postMessage`. + +## Install + +Install the official package before using the SDK in starter code: + +```bash +pnpm add @labring/sealos-desktop-sdk +``` + +Implications: + +1. The SDK must be initialized in the browser. +2. Session-dependent calls usually succeed only when the page is opened by Sealos Desktop. +3. Repeated initialization can create noisy listeners or stale instances. + +## Common imports + +Use the official package name in examples: + +```ts +import { EVENT_NAME } from '@labring/sealos-desktop-sdk'; +import { createSealosApp, sealosApp } from '@labring/sealos-desktop-sdk/app'; +``` + +If a specific repository already exposes the SDK through a local workspace alias, preserve that only when the repository clearly depends on it. + +## Core app-side APIs + +### `createSealosApp()` + +Initialize the SDK in a client-only context. + +Typical behavior: + +1. Register the `message` listener. +2. Create the request-response bridge to Desktop. +3. Return a cleanup function when supported by the implementation. + +Use it once near the app root. + +### `sealosApp.getSession()` + +Fetch the current Sealos session. + +Typical useful fields: + +```ts +type SessionUser = { + id: string; + name: string; + avatar: string; + k8sUsername: string; + nsid: string; +}; +``` + +Use `session.user.id` as the stable business key unless the repository uses a stronger existing convention. + +### `sealosApp.getLanguage()` + +Fetch the current Desktop language. + +Typical response: + +```ts +{ lng: 'en' } +``` + +### `sealosApp.addAppEventListen(EVENT_NAME.CHANGE_I18N, handler)` + +Listen for language changes emitted by Desktop. + +Use this only when runtime language changes matter. Many simple apps can read language once and stop there. + +### `sealosApp.getWorkspaceQuota()` + +Fetch workspace quota information. Use this when the app creates or provisions resources that should respect workspace limits. + +### `sealosApp.getHostConfig()` + +Fetch host configuration and feature flags, such as subscription availability or cloud-domain details. + +### `sealosApp.runEvents(name, data)` + +Send an event to Desktop. + +Important limitation: + +This is only useful when Desktop actually implements the named event. Treat event names such as `openDesktopApp` as platform conventions, not universally guaranteed APIs. + +## Practical rules + +1. Never call the SDK from the server. +2. Expect failure when the app is opened directly in a browser tab. +3. Keep one root integration layer. +4. Prefer a user-facing fallback message instead of silent failure. diff --git a/plugins/labring/sealos-skills/skills/sealos-canvas/SKILL.md b/plugins/labring/sealos-skills/skills/sealos-canvas/SKILL.md new file mode 100644 index 00000000..952ea12f --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-canvas/SKILL.md @@ -0,0 +1,116 @@ +--- +name: sealos-canvas +description: Run a local read-only HTML topology UI for a project already deployed by Sealos Skills and return a localhost URL. Use when the user asks to view, inspect, visualize, render, open, or run a local canvas for deployed Sealos resources, mentions ".sealos", Sealos deployment state, Kubernetes resources, topology, resource graph, localhost UI, or invokes "/sealos-canvas". +--- + +# Sealos Canvas + +## Overview + +Render the current repository's deployed Sealos resources as a locally hosted HTML canvas. This skill is view-only: it reads `.sealos/state.json` and Kubernetes resources, starts a temporary `127.0.0.1` UI server, and returns the local URL to the user. + +## Hard Rules + +1. Do not deploy, update, restart, patch, delete, or apply resources. +2. Only use read commands such as `kubectl get` and `kubectl config view`. +3. Use the Sealos kubeconfig at `~/.sealos/kubeconfig`. +4. Do not display Secret data or full ConfigMap contents. +5. If the project has no `.sealos/state.json` with `last_deploy`, stop and tell the user to deploy first with `/sealos-deploy`. +6. If kubeconfig or live resource access is unavailable, report the script message and stop. + +## Workflow + +### 1. Resolve the project + +Use the current working directory unless the user provides a local path: + +```bash +WORK_DIR="$(pwd)" +``` + +Confirm this is the intended repository before generating output. + +### 2. Start the local canvas UI + +Run: + +```bash +node "/scripts/generate-canvas.mjs" --work-dir "$WORK_DIR" +``` + +Keep this process running while the user is viewing the canvas. The script writes JSON to stdout after the local server starts. + +If `ok` is `false`, show the `message` to the user and end the flow. Do not run fallback discovery, do not deploy, and do not create any other artifact. + +If `ok` is `true`, use `local_url` as the primary output. + +### 3. Open the local URL + +Open the returned URL with the browser: + +```text +http://127.0.0.1:/index.html +``` + +Then summarize: + +- local UI URL +- app URL +- node count +- edge count + +Stop the server process when the user is done viewing the page or when the current task ends. + +## Output Contract + +Success: + +```json +{ + "ok": true, + "local_url": "http://127.0.0.1:63220/index.html", + "html_path": "/abs/path/.sealos/canvas/index.html", + "node_count": 5, + "edge_count": 4, + "app_url": "https://example.sealos.run" +} +``` + +Stop condition: + +```json +{ + "ok": false, + "reason": "not_deployed", + "message": "This project has not been deployed by Sealos Skills yet. Run /sealos-deploy first, then run /sealos-canvas again." +} +``` + +## Visual Target + +The locally hosted UI should feel like a topology canvas, not a table: + +1. Top bar with app name, namespace, deployed app URL, generated time, and local UI status. +2. Dark dotted-grid canvas with deterministic resource-card layout. +3. Resource cards for app, ingress, services, pods, config, secrets, and volumes. +4. Dashed or solid SVG connector lines between related resources. +5. PVC/volume references attached as strips on the related card when possible. +6. Detail panel for the selected resource. +7. Events panel with recent related Kubernetes events. +8. Status colors for ready, sleeping, warning, and failed states. +9. Lightweight pan, zoom, fit, and reset controls. + +Theme extraction is best effort. Reuse the user's repo accent color, font, and radius when easy to detect, but preserve operational readability. + +## Script + +`scripts/generate-canvas.mjs` is the deterministic entrypoint. It: + +1. Reads `.sealos/state.json`. +2. Verifies `~/.sealos/kubeconfig` and `kubectl`. +3. Reads live namespace resources with `kubectl get`. +4. Builds a sanitized `canvasModel` with `app`, `nodes`, `edges`, `events`, and `theme`. +5. Renders `assets/canvas-template.html` into an internal `.sealos/canvas/index.html` cache. +6. Starts a temporary local HTTP server and prints `local_url`. + +Use `--no-serve` only for tests or CI checks that should generate HTML without keeping a server process alive. diff --git a/plugins/labring/sealos-skills/skills/sealos-canvas/agents/openai.yaml b/plugins/labring/sealos-skills/skills/sealos-canvas/agents/openai.yaml new file mode 100644 index 00000000..8962404b --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-canvas/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sealos Canvas" + short_description: "Run a local Sealos resource canvas UI." + default_prompt: "Use $sealos-canvas to run a local read-only UI for this repo's deployed Sealos resources." diff --git a/plugins/labring/sealos-skills/skills/sealos-canvas/assets/canvas-template.html b/plugins/labring/sealos-skills/skills/sealos-canvas/assets/canvas-template.html new file mode 100644 index 00000000..c55e6446 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-canvas/assets/canvas-template.html @@ -0,0 +1,876 @@ + + + + + + __TITLE__ + + + + +
+
+
+
+

+
+ + + + +
+
+
+ +
+ +
+
+ +
+
+
+ + + + +
+ + + + + diff --git a/plugins/labring/sealos-skills/skills/sealos-canvas/evals/evals.json b/plugins/labring/sealos-skills/skills/sealos-canvas/evals/evals.json new file mode 100644 index 00000000..eee94a0e --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-canvas/evals/evals.json @@ -0,0 +1,77 @@ +{ + "skill_name": "sealos-canvas", + "evals": [ + { + "id": 0, + "prompt": "/sealos-canvas in a repository without .sealos/state.json", + "expected_output": "Stops with a message telling the user to run /sealos-deploy first and does not generate HTML", + "files": [], + "assertions": [ + { + "name": "detects-not-deployed", + "description": "Returns ok=false with reason not_deployed" + }, + { + "name": "does-not-create-canvas", + "description": "Does not create .sealos/canvas/index.html" + } + ] + }, + { + "id": 1, + "prompt": "/sealos-canvas in a deployed repo when ~/.sealos/kubeconfig is unavailable", + "expected_output": "Stops with a kubeconfig unavailable message and does not attempt deploy/update behavior", + "files": [], + "assertions": [ + { + "name": "requires-kubeconfig", + "description": "Returns ok=false with reason kubeconfig_missing" + }, + { + "name": "view-only", + "description": "Does not run kubectl apply, patch, delete, rollout, restart, or set image commands" + } + ] + }, + { + "id": 2, + "prompt": "/sealos-canvas in a deployed repo with mock Kubernetes resources", + "expected_output": "Starts a local canvas UI, returns local_url, and renders app, URL, resource cards, volume strip, details, and SVG edges", + "files": [], + "assertions": [ + { + "name": "returns-local-url", + "description": "Returns ok=true with a http://127.0.0.1 local_url" + }, + { + "name": "renders-topology", + "description": "Local UI contains resource nodes and connector edges" + }, + { + "name": "includes-volume-strip", + "description": "PVC or volume references are rendered as attached strips" + }, + { + "name": "includes-detail-panel", + "description": "Local UI includes a selected resource detail panel" + } + ] + }, + { + "id": 3, + "prompt": "/sealos-canvas with Secret resources in the namespace", + "expected_output": "Shows only Secret names/types/reference relationships and never writes Secret data values to HTML", + "files": [], + "assertions": [ + { + "name": "does-not-render-secret-data", + "description": "HTML does not contain Secret .data values" + }, + { + "name": "renders-secret-safely", + "description": "Secret nodes show names and metadata only" + } + ] + } + ] +} diff --git a/plugins/labring/sealos-skills/skills/sealos-canvas/scripts/generate-canvas.mjs b/plugins/labring/sealos-skills/skills/sealos-canvas/scripts/generate-canvas.mjs new file mode 100644 index 00000000..920709ec --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-canvas/scripts/generate-canvas.mjs @@ -0,0 +1,733 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const SKILL_DIR = path.dirname(__dirname) +const TEMPLATE_PATH = path.join(SKILL_DIR, 'assets', 'canvas-template.html') + +const SAFE_RESOURCE_KINDS = [ + 'deployment', + 'pod', + 'service', + 'ingress', + 'persistentvolumeclaim', + 'event' +] + +function main() { + const args = parseArgs(process.argv.slice(2)) + const workDir = path.resolve(args.workDir || process.cwd()) + const statePath = path.join(workDir, '.sealos', 'state.json') + + const state = readJsonIfExists(statePath) + const lastDeploy = state?.last_deploy + if (!lastDeploy?.app_name || !lastDeploy?.namespace) { + return printStop('not_deployed', 'This project has not been deployed by Sealos Skills yet. Run /sealos-deploy first, then run /sealos-canvas again.') + } + + let resources + const fixturePath = process.env.SEALOS_CANVAS_KUBE_FIXTURE + if (fixturePath) { + resources = readJson(path.resolve(fixturePath)) + } else { + const kubeconfig = path.join(os.homedir(), '.sealos', 'kubeconfig') + if (!fs.existsSync(kubeconfig)) { + return printStop('kubeconfig_missing', 'Sealos kubeconfig was not found at ~/.sealos/kubeconfig. Run /sealos-deploy first, then run /sealos-canvas again.') + } + + const kubectl = findKubectl() + if (!kubectl) { + return printStop('kubectl_missing', 'kubectl is required to view deployed Sealos resources. Install kubectl, then run /sealos-canvas again.') + } + + resources = readLiveResources({ kubectl, kubeconfig, namespace: lastDeploy.namespace }) + } + + const theme = extractTheme(workDir) + const graph = buildGraph(lastDeploy, resources) + const canvasModel = buildCanvasModel({ graph, theme, lastDeploy }) + const html = renderHtml({ canvasModel }) + const outputDir = path.join(workDir, '.sealos', 'canvas') + const outputPath = path.join(outputDir, 'index.html') + + fs.mkdirSync(outputDir, { recursive: true }) + fs.writeFileSync(outputPath, html) + + if (args.serve) { + return serveCanvas({ host: args.host, port: args.port, outputDir, outputPath, graph, lastDeploy }) + } + + return printJson({ + ok: true, + html_path: outputPath, + node_count: graph.nodes.length, + edge_count: graph.edges.length, + app_url: lastDeploy.url || '' + }) +} + +function parseArgs(argv) { + const args = { serve: true, host: '127.0.0.1', port: 0 } + for (let index = 0; index < argv.length; index++) { + const item = argv[index] + if (item === '--work-dir') { + args.workDir = argv[++index] + } else if (item === '--host') { + args.host = argv[++index] + } else if (item === '--port') { + args.port = Number(argv[++index]) + } else if (item === '--no-serve') { + args.serve = false + } else if (item === '--help' || item === '-h') { + process.stdout.write('Usage: node generate-canvas.mjs --work-dir [--host 127.0.0.1] [--port 0] [--no-serve]\n') + process.exit(0) + } + } + return args +} + +function readJsonIfExists(filePath) { + if (!fs.existsSync(filePath)) return null + try { + return readJson(filePath) + } catch { + return null + } +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +function printStop(reason, message) { + printJson({ ok: false, reason, message }) +} + +function printJson(data) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`) +} + +function serveCanvas({ host, port, outputDir, outputPath, graph, lastDeploy }) { + const server = http.createServer((request, response) => { + const url = new URL(request.url || '/', `http://${host}`) + const pathname = url.pathname === '/' ? '/index.html' : url.pathname + + if (pathname !== '/index.html') { + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + response.end('Not found') + return + } + + fs.createReadStream(path.join(outputDir, 'index.html')) + .on('error', () => { + response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }) + response.end('Canvas HTML is unavailable') + }) + .on('open', () => { + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store' + }) + }) + .pipe(response) + }) + + server.on('error', (error) => { + printJson({ + ok: false, + reason: 'server_start_failed', + message: `Failed to start local Sealos canvas server: ${error.message}` + }) + process.exitCode = 1 + }) + + server.listen(port, host, () => { + const address = server.address() + const actualPort = typeof address === 'object' && address ? address.port : port + const localUrl = `http://${host}:${actualPort}/index.html` + printJson({ + ok: true, + local_url: localUrl, + html_path: outputPath, + node_count: graph.nodes.length, + edge_count: graph.edges.length, + app_url: lastDeploy.url || '' + }) + }) + + const shutdown = () => { + server.close(() => process.exit(0)) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) +} + +function findKubectl() { + const candidates = ['kubectl', path.join(os.homedir(), '.agents', 'bin', 'kubectl')] + for (const candidate of candidates) { + try { + execFileSync(candidate, ['version', '--client=true'], { stdio: 'ignore', timeout: 10000 }) + return candidate + } catch { + // Try the next candidate. + } + } + return null +} + +function readLiveResources({ kubectl, kubeconfig, namespace }) { + const env = { ...process.env, KUBECONFIG: kubeconfig } + const resources = {} + + for (const kind of SAFE_RESOURCE_KINDS) { + try { + const stdout = execFileSync( + kubectl, + ['--insecure-skip-tls-verify', '--request-timeout=8s', 'get', kind, '-n', namespace, '-o', 'json'], + { env, encoding: 'utf8', timeout: 12000, maxBuffer: 12 * 1024 * 1024 } + ) + resources[toResourceKey(kind)] = JSON.parse(stdout) + } catch (error) { + resources[toResourceKey(kind)] = { apiVersion: 'v1', items: [], error: readableExecError(error) } + } + } + + resources.configmaps = readConfigMapSummaries({ kubectl, env, namespace }) + resources.secrets = readSecretSummaries({ kubectl, env, namespace }) + + return resources +} + +function readConfigMapSummaries({ kubectl, env, namespace }) { + try { + const template = '{{range .items}}{{.metadata.name}}{{"\\t"}}{{len .data}}{{"\\n"}}{{end}}' + const stdout = execFileSync( + kubectl, + ['--insecure-skip-tls-verify', '--request-timeout=8s', 'get', 'configmap', '-n', namespace, '-o', `go-template=${template}`], + { env, encoding: 'utf8', timeout: 12000, maxBuffer: 1024 * 1024 } + ) + return { + apiVersion: 'v1', + items: stdout.trim().split('\n').filter(Boolean).map((line) => { + const [name, keyCount] = line.split('\t') + return { metadata: { name }, dataKeyCount: Number(keyCount || 0) } + }) + } + } catch (error) { + return { apiVersion: 'v1', items: [], error: readableExecError(error) } + } +} + +function readSecretSummaries({ kubectl, env, namespace }) { + try { + const template = '{{range .items}}{{.metadata.name}}{{"\\t"}}{{.type}}{{"\\n"}}{{end}}' + const stdout = execFileSync( + kubectl, + ['--insecure-skip-tls-verify', '--request-timeout=8s', 'get', 'secret', '-n', namespace, '-o', `go-template=${template}`], + { env, encoding: 'utf8', timeout: 12000, maxBuffer: 1024 * 1024 } + ) + return { + apiVersion: 'v1', + items: stdout.trim().split('\n').filter(Boolean).map((line) => { + const [name, type] = line.split('\t') + return { metadata: { name }, type: type || 'Opaque' } + }) + } + } catch (error) { + return { apiVersion: 'v1', items: [], error: readableExecError(error) } + } +} + +function readableExecError(error) { + const text = String(error.stderr || error.message || error) + return text.trim().slice(0, 500) +} + +function toResourceKey(kind) { + const map = { + deployment: 'deployments', + pod: 'pods', + service: 'services', + ingress: 'ingresses', + persistentvolumeclaim: 'persistentvolumeclaims', + configmap: 'configmaps', + secret: 'secrets', + event: 'events' + } + return map[kind] || kind +} + +function extractTheme(workDir) { + const theme = { + accent: '#6c55ff', + radius: '8px', + font: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' + } + + const files = [ + 'tailwind.config.js', + 'tailwind.config.ts', + 'src/app/globals.css', + 'app/globals.css', + 'src/styles/globals.css', + 'src/styles/theme.css', + 'styles/globals.css', + 'package.json' + ].map((item) => path.join(workDir, item)) + + for (const filePath of files) { + if (!fs.existsSync(filePath) || fs.statSync(filePath).size > 200000) continue + const content = fs.readFileSync(filePath, 'utf8') + const accent = findAccent(content) + if (accent) theme.accent = accent + + const radius = content.match(/--radius:\s*([^;\n]+)/)?.[1]?.trim() + || content.match(/borderRadius:\s*\{[\s\S]*?(?:DEFAULT|lg):\s*['"]([^'"]+)['"]/)?.[1] + if (radius && radius.length < 32) theme.radius = normalizeRadius(radius) + + const font = content.match(/--font-(?:sans|body):\s*([^;\n]+)/)?.[1]?.trim() + || content.match(/fontFamily:\s*\{[\s\S]*?sans:\s*\[([^\]]+)/)?.[1]?.replaceAll("'", '').replaceAll('"', '').trim() + if (font && font.length < 120) theme.font = `${font}, ${theme.font}` + } + + return theme +} + +function findAccent(content) { + const patterns = [ + /--(?:primary|accent|brand):\s*(#[0-9a-fA-F]{3,8})/, + /(?:primary|accent|brand):\s*['"](#[0-9a-fA-F]{3,8})['"]/ + ] + for (const pattern of patterns) { + const match = content.match(pattern) + if (match) return match[1] || match[0] + } + return null +} + +function normalizeRadius(value) { + if (value.includes('var(') || value.includes('calc(')) return '8px' + if (/^\d+(\.\d+)?(px|rem|em)$/.test(value)) { + const match = value.match(/^(\d+(?:\.\d+)?)(px|rem|em)$/) + if (!match) return '8px' + if (match[2] === 'px') return `${Math.min(Number(match[1]), 8)}px` + if (match[2] === 'rem') return `${Math.min(Number(match[1]), 0.5)}rem` + return `${Math.min(Number(match[1]), 0.5)}em` + } + return '8px' +} + +function buildGraph(lastDeploy, resources) { + const appName = lastDeploy.app_name + const namespace = lastDeploy.namespace + const deployments = items(resources.deployments) + const pods = items(resources.pods) + const services = items(resources.services) + const ingresses = items(resources.ingresses) + const pvcs = items(resources.persistentvolumeclaims) + const configmaps = items(resources.configmaps) + const secrets = items(resources.secrets) + const events = sanitizeEvents(items(resources.events), appName) + + const deployment = deployments.find((item) => nameOf(item) === appName) + || deployments.find((item) => nameOf(item)?.startsWith(appName)) + || deployments.find((item) => includesAppLabel(item, appName)) + + const deploymentName = nameOf(deployment) || appName + const selector = deployment?.spec?.selector?.matchLabels || {} + const relatedPods = pods.filter((pod) => isOwnedBy(pod, deploymentName) || labelsMatch(pod.metadata?.labels, selector) || nameOf(pod)?.startsWith(deploymentName)) + const relatedServices = services.filter((service) => serviceTargetsPods(service, relatedPods) || nameOf(service) === appName || includesAppLabel(service, appName)) + const relatedIngresses = ingresses.filter((ingress) => ingressTargetsServices(ingress, relatedServices) || ingressHostsUrl(ingress, lastDeploy.url)) + const volumeClaims = collectVolumeClaims(deployment, relatedPods, pvcs) + const configRefs = collectConfigRefs(deployment, relatedPods, configmaps) + const secretRefs = collectSecretRefs(deployment, relatedPods, secrets) + + const nodes = [] + const edges = [] + + const appNode = { + id: 'app', + kind: 'Application', + title: appName, + subtitle: lastDeploy.url || `${deploymentName}.${namespace}`, + status: statusForDeployment(deployment), + statusText: statusTextForDeployment(deployment), + icon: 'app', + meta: [ + ['Namespace', namespace], + ['Image', lastDeploy.image || firstContainerImage(deployment) || 'unknown'], + ['Updated', lastDeploy.last_updated_at || lastDeploy.deployed_at || 'unknown'] + ], + attachments: volumeClaims.map((pvc) => ({ icon: 'volume', label: `${nameOf(pvc)} volume` })) + } + nodes.push(appNode) + + if (relatedIngresses.length > 0 || lastDeploy.url) { + nodes.push({ + id: 'ingress', + kind: 'Ingress', + title: relatedIngresses[0] ? nameOf(relatedIngresses[0]) : 'Public URL', + subtitle: lastDeploy.url || firstIngressHost(relatedIngresses[0]) || 'external access', + status: 'ready', + statusText: 'Published', + icon: 'ingress', + meta: [ + ['Host', stripProtocol(lastDeploy.url) || firstIngressHost(relatedIngresses[0]) || 'unknown'], + ['Rules', String(relatedIngresses.reduce((total, ingress) => total + (ingress.spec?.rules?.length || 0), 0) || 1)] + ] + }) + edges.push({ from: 'ingress', to: 'app', label: 'routes', strong: true }) + } + + if (relatedServices.length > 0) { + nodes.push({ + id: 'service', + kind: 'Service', + title: relatedServices.map(nameOf).filter(Boolean).join(', '), + subtitle: 'Cluster networking', + status: 'ready', + statusText: `${relatedServices.length} service${relatedServices.length === 1 ? '' : 's'}`, + icon: 'service', + meta: [ + ['Ports', relatedServices.flatMap((svc) => (svc.spec?.ports || []).map((port) => `${port.port}${port.targetPort ? `->${port.targetPort}` : ''}`)).join(', ') || 'none'], + ['Type', [...new Set(relatedServices.map((svc) => svc.spec?.type || 'ClusterIP'))].join(', ')] + ] + }) + edges.push({ from: 'app', to: 'service', label: 'exposes' }) + } + + if (relatedPods.length > 0) { + const readyCount = relatedPods.filter(podReady).length + nodes.push({ + id: 'pods', + kind: 'Pods', + title: `${readyCount}/${relatedPods.length} pods ready`, + subtitle: relatedPods.map(nameOf).filter(Boolean).slice(0, 2).join(', '), + status: readyCount === relatedPods.length ? 'ready' : readyCount === 0 ? 'failed' : 'warning', + statusText: readyCount === relatedPods.length ? 'Running' : 'Needs attention', + icon: 'pod', + meta: [ + ['Restart count', String(totalRestarts(relatedPods))], + ['Phase', [...new Set(relatedPods.map((pod) => pod.status?.phase || 'Unknown'))].join(', ')] + ], + attachments: volumeClaims.map((pvc) => ({ icon: 'volume', label: `${nameOf(pvc)} volume` })) + }) + edges.push({ from: 'app', to: 'pods', label: 'runs' }) + } + + if (configRefs.length > 0) { + nodes.push({ + id: 'config', + kind: 'Config', + title: `${configRefs.length} config reference${configRefs.length === 1 ? '' : 's'}`, + subtitle: configRefs.map((item) => item.name).slice(0, 3).join(', '), + status: 'ready', + statusText: 'Referenced', + icon: 'config', + meta: configRefs.slice(0, 4).map((item) => [item.kind, item.detail]) + }) + edges.push({ from: 'config', to: 'app', label: 'injects' }) + } + + if (secretRefs.length > 0) { + nodes.push({ + id: 'secrets', + kind: 'Secrets', + title: `${secretRefs.length} secret reference${secretRefs.length === 1 ? '' : 's'}`, + subtitle: secretRefs.map((item) => item.name).slice(0, 3).join(', '), + status: 'warning', + statusText: 'Names only', + icon: 'secret', + meta: secretRefs.slice(0, 4).map((item) => [item.kind, item.detail]) + }) + edges.push({ from: 'secrets', to: 'app', label: 'injects' }) + } + + if (volumeClaims.length > 0) { + nodes.push({ + id: 'storage', + kind: 'Storage', + title: `${volumeClaims.length} persistent volume${volumeClaims.length === 1 ? '' : 's'}`, + subtitle: volumeClaims.map(nameOf).filter(Boolean).join(', '), + status: volumeClaims.every((pvc) => pvc.status?.phase === 'Bound') ? 'ready' : 'warning', + statusText: volumeClaims.every((pvc) => pvc.status?.phase === 'Bound') ? 'Bound' : 'Pending', + icon: 'volume', + meta: volumeClaims.slice(0, 4).map((pvc) => [nameOf(pvc), pvc.spec?.resources?.requests?.storage || pvc.status?.phase || 'volume']) + }) + edges.push({ from: 'app', to: 'storage', label: 'mounts' }) + } + + return layoutGraph({ nodes, edges, events, namespace, appName }) +} + +function items(resourceList) { + return Array.isArray(resourceList?.items) ? resourceList.items : [] +} + +function nameOf(resource) { + return resource?.metadata?.name || '' +} + +function includesAppLabel(resource, appName) { + const labels = resource?.metadata?.labels || {} + return Object.values(labels).some((value) => String(value).includes(appName)) +} + +function isOwnedBy(resource, ownerName) { + return (resource?.metadata?.ownerReferences || []).some((owner) => owner.name === ownerName) +} + +function labelsMatch(labels = {}, selector = {}) { + const entries = Object.entries(selector) + return entries.length > 0 && entries.every(([key, value]) => labels[key] === value) +} + +function serviceTargetsPods(service, pods) { + const selector = service?.spec?.selector || {} + return Object.keys(selector).length > 0 && pods.some((pod) => labelsMatch(pod.metadata?.labels, selector)) +} + +function ingressTargetsServices(ingress, services) { + const names = new Set(services.map(nameOf)) + const backends = [] + for (const rule of ingress?.spec?.rules || []) { + for (const pathItem of rule.http?.paths || []) { + if (pathItem.backend?.service?.name) backends.push(pathItem.backend.service.name) + } + } + if (ingress?.spec?.defaultBackend?.service?.name) backends.push(ingress.spec.defaultBackend.service.name) + return backends.some((name) => names.has(name)) +} + +function ingressHostsUrl(ingress, url) { + const host = stripProtocol(url) + if (!host) return false + return (ingress?.spec?.rules || []).some((rule) => rule.host === host) +} + +function firstIngressHost(ingress) { + return ingress?.spec?.rules?.[0]?.host || '' +} + +function stripProtocol(url = '') { + return String(url).replace(/^https?:\/\//, '').replace(/\/$/, '') +} + +function statusForDeployment(deployment) { + if (!deployment) return 'warning' + const desired = deployment.spec?.replicas ?? 1 + const ready = deployment.status?.readyReplicas || 0 + if (desired === 0) return 'sleeping' + if (ready >= desired) return 'ready' + if (ready === 0) return 'failed' + return 'warning' +} + +function statusTextForDeployment(deployment) { + if (!deployment) return 'Deployment not found' + const desired = deployment.spec?.replicas ?? 1 + const ready = deployment.status?.readyReplicas || 0 + if (desired === 0) return 'Sleeping' + if (ready >= desired) return 'Running' + if (ready === 0) return 'Unavailable' + return `${ready}/${desired} ready` +} + +function firstContainerImage(deployment) { + return deployment?.spec?.template?.spec?.containers?.[0]?.image || '' +} + +function podReady(pod) { + return (pod.status?.conditions || []).some((condition) => condition.type === 'Ready' && condition.status === 'True') +} + +function totalRestarts(pods) { + return pods.reduce((total, pod) => total + (pod.status?.containerStatuses || []).reduce((sum, container) => sum + (container.restartCount || 0), 0), 0) +} + +function collectVolumeClaims(deployment, pods, pvcs) { + const names = new Set() + for (const spec of [deployment?.spec?.template?.spec, ...pods.map((pod) => pod.spec)]) { + for (const volume of spec?.volumes || []) { + if (volume.persistentVolumeClaim?.claimName) names.add(volume.persistentVolumeClaim.claimName) + } + } + return pvcs.filter((pvc) => names.has(nameOf(pvc))) +} + +function collectConfigRefs(deployment, pods, configmaps) { + const existing = new Map(configmaps.map((item) => [nameOf(item), item])) + const refs = new Map() + for (const container of allContainers(deployment, pods)) { + for (const envFrom of container.envFrom || []) { + if (envFrom.configMapRef?.name) addConfigRef(refs, existing, envFrom.configMapRef.name, 'ConfigMap', 'envFrom') + } + for (const env of container.env || []) { + if (env.valueFrom?.configMapKeyRef?.name) addConfigRef(refs, existing, env.valueFrom.configMapKeyRef.name, 'ConfigMap', `key ${env.valueFrom.configMapKeyRef.key || env.name}`) + } + } + for (const spec of [deployment?.spec?.template?.spec, ...pods.map((pod) => pod.spec)]) { + for (const volume of spec?.volumes || []) { + if (volume.configMap?.name) addConfigRef(refs, existing, volume.configMap.name, 'ConfigMap', 'mounted volume') + } + } + return [...refs.values()] +} + +function addConfigRef(refs, existing, name, kind, detail) { + const config = existing.get(name) + const keyCount = config?.dataKeyCount || 0 + refs.set(`${kind}:${name}:${detail}`, { name, kind, detail: keyCount ? `${detail}, ${keyCount} keys` : detail }) +} + +function collectSecretRefs(deployment, pods, secrets) { + const existing = new Map(secrets.map((item) => [nameOf(item), item])) + const refs = new Map() + for (const container of allContainers(deployment, pods)) { + for (const envFrom of container.envFrom || []) { + if (envFrom.secretRef?.name) addSecretRef(refs, existing, envFrom.secretRef.name, 'Secret', 'envFrom') + } + for (const env of container.env || []) { + if (env.valueFrom?.secretKeyRef?.name) addSecretRef(refs, existing, env.valueFrom.secretKeyRef.name, 'Secret', `key ${env.valueFrom.secretKeyRef.key || env.name}`) + } + } + for (const spec of [deployment?.spec?.template?.spec, ...pods.map((pod) => pod.spec)]) { + for (const volume of spec?.volumes || []) { + if (volume.secret?.secretName) addSecretRef(refs, existing, volume.secret.secretName, 'Secret', 'mounted volume') + } + for (const secret of spec?.imagePullSecrets || []) { + if (secret.name) addSecretRef(refs, existing, secret.name, 'ImagePullSecret', 'image pull') + } + } + return [...refs.values()] +} + +function addSecretRef(refs, existing, name, kind, detail) { + const secret = existing.get(name) + refs.set(`${kind}:${name}:${detail}`, { name, kind, detail: secret?.type ? `${detail}, ${secret.type}` : detail }) +} + +function allContainers(deployment, pods) { + const containers = [] + containers.push(...(deployment?.spec?.template?.spec?.containers || [])) + for (const pod of pods) containers.push(...(pod.spec?.containers || [])) + return containers +} + +function sanitizeEvents(events, appName) { + return events + .filter((event) => { + const involved = event.involvedObject?.name || '' + return involved.includes(appName) || String(event.message || '').includes(appName) + }) + .sort((a, b) => String(b.lastTimestamp || b.eventTime || b.metadata?.creationTimestamp || '').localeCompare(String(a.lastTimestamp || a.eventTime || a.metadata?.creationTimestamp || ''))) + .slice(0, 8) + .map((event) => ({ + type: event.type || 'Normal', + reason: event.reason || 'Event', + involved: event.involvedObject?.name || '', + message: String(event.message || '').slice(0, 180), + time: event.lastTimestamp || event.eventTime || event.metadata?.creationTimestamp || '' + })) +} + +function layoutGraph(graph) { + const preferred = { + ingress: [80, 80], + app: [520, 120], + service: [520, 390], + pods: [980, 120], + config: [80, 390], + secrets: [80, 630], + storage: [980, 390] + } + const fallback = [[520, 630], [980, 630], [1440, 120], [1440, 390]] + let fallbackIndex = 0 + + for (const node of graph.nodes) { + const coords = preferred[node.id] || fallback[fallbackIndex++] || [80 + fallbackIndex * 440, 80] + node.x = coords[0] + node.y = coords[1] + node.width = ['config', 'secrets'].includes(node.id) ? 330 : 390 + node.height = estimateNodeHeight(node) + } + + graph.width = Math.max(1320, ...graph.nodes.map((node) => node.x + node.width + 100)) + graph.height = Math.max(820, ...graph.nodes.map((node) => node.y + node.height + 100)) + return graph +} + +function estimateNodeHeight(node) { + const attachments = node.attachments?.length || 0 + const meta = Math.min(node.meta?.length || 0, 4) + return 132 + meta * 24 + attachments * 58 +} + +function buildCanvasModel({ graph, theme, lastDeploy }) { + return { + generatedAt: new Date().toISOString(), + app: { + name: lastDeploy.app_name, + namespace: lastDeploy.namespace, + url: lastDeploy.url || '', + image: lastDeploy.image || '', + status: graph.nodes.find((node) => node.id === 'app')?.status || 'warning', + updatedAt: lastDeploy.last_updated_at || lastDeploy.deployed_at || '' + }, + layout: { + width: graph.width, + height: graph.height + }, + nodes: graph.nodes, + edges: graph.edges, + events: graph.events, + theme + } +} + +function renderHtml({ canvasModel }) { + const template = fs.readFileSync(TEMPLATE_PATH, 'utf8') + const title = `${canvasModel.app.name} - Sealos Canvas` + + return template + .replaceAll('__TITLE__', escapeHtml(title)) + .replace('__CANVAS_MODEL__', escapeScriptJson(canvasModel)) +} + +function escapeHtml(value = '') { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function escapeCss(value = '') { + return String(value).replace(/[<>]/g, '') +} + +function escapeScriptJson(data) { + return JSON.stringify(data) + .replaceAll('<', '\\u003c') + .replaceAll('>', '\\u003e') + .replaceAll('&', '\\u0026') + .replaceAll('\u2028', '\\u2028') + .replaceAll('\u2029', '\\u2029') +} + +try { + main() +} catch (error) { + printJson({ + ok: false, + reason: 'canvas_generation_failed', + message: `Failed to generate Sealos canvas: ${error.message}` + }) +} diff --git a/plugins/labring/sealos-skills/skills/sealos-database/SKILL.md b/plugins/labring/sealos-skills/skills/sealos-database/SKILL.md new file mode 100644 index 00000000..e022d33b --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-database/SKILL.md @@ -0,0 +1,167 @@ +--- +name: sealos-database +description: Provision, connect, and operate Sealos Cloud databases through sealos-cli for local development, Devbox development, and app setup. Use when the user needs a cloud database for a project, asks to create or connect PostgreSQL/MySQL/MongoDB/Redis or another Sealos database, wants DATABASE_URL or similar env vars wired into a dev environment, needs database connection details, backups, logs, public access, or wants to replace local Docker Compose databases with a managed Sealos database. +--- + +# Sealos Database + +Use this skill to give a project a real Sealos Cloud database during development. The default outcome is: identify the app's database need, create or reuse a Sealos database with `sealos-cli`, fetch connection details, wire only the needed local env vars, and verify the app can connect. + +## Safety Rules + +1. Never print database passwords or full connection strings in the final answer. +2. Do not overwrite an existing env value without confirming or preserving the old value. +3. Do not commit `.env`, `.env.local`, connection strings, passwords, kubeconfig, or Sealos auth files. +4. Ask before enabling public database access. Prefer private connections when the app runs inside Sealos/Devbox. +5. Ask before destructive operations: `database delete`, `backup-delete`, restoring over a name that may collide, or disabling access that an active app depends on. +6. Use JSON output from `sealos-cli` by default and parse it instead of scraping table output. + +## Workflow + +### 1. Resolve the target project + +Confirm the working directory with `pwd` or `git rev-parse --show-toplevel`. + +Run the analyzer when a project directory is available: + +```bash +node /scripts/analyze-project-database.mjs +``` + +Use the analyzer result as a starting point, then inspect the real files it cites before editing anything. It intentionally avoids printing secret values. + +### 2. Check `sealos-cli` + +Prefer an existing `sealos-cli` binary: + +```bash +sealos-cli --version +sealos-cli database --help +sealos-cli whoami +``` + +If it is not installed, use `npx -y sealos-cli@latest ...` for one-off commands. Ask before installing it globally. + +If auth is missing or expired, run: + +```bash +sealos-cli login +sealos-cli workspace list +sealos-cli workspace current +``` + +Use the workspace the user expects. If multiple workspaces exist and the target is ambiguous, ask before provisioning. + +### 3. Choose create or reuse + +List existing databases first: + +```bash +sealos-cli database list -o json +``` + +Reuse an existing database when the name, type, and purpose match. Create a new one when the project has no suitable database or the user asks for a fresh dev database. + +Use conservative development defaults unless the project clearly needs more: + +```bash +sealos-cli database create postgresql --name --cpu 1 --memory 1 --storage 3 --replicas 1 -o json +``` + +Before creating, check supported versions if version choice matters: + +```bash +sealos-cli database versions --type postgresql -o json +``` + +Supported CLI database types include `postgresql`, `mongodb`, `mysql`, `apecloud-mysql`, `redis`, `kafka`, `qdrant`, `nebula`, `weaviate`, `milvus`, `pulsar`, and `clickhouse`. Use the type detected from the project; default to `postgresql` only when the project has no database-specific signals. + +### 4. Wait for readiness and fetch connection data + +Poll details until the database is running or connection data is present: + +```bash +sealos-cli database get -o json +sealos-cli database connection -o json +``` + +Read `references/sealos-cli-database.md` for the current command contract and response handling. + +### 5. Wire the development environment + +Map the connection into the env var the project already uses: + +| Project signal | Preferred env key | +| --- | --- | +| Prisma, Drizzle, TypeORM, generic Postgres | `DATABASE_URL` | +| MySQL app with existing MySQL-specific config | `DATABASE_URL` or existing `MYSQL_URL` | +| MongoDB app | `MONGODB_URI` | +| Redis cache/queue | `REDIS_URL` | + +Use the existing local env convention: + +1. Prefer `.env.local` for Next.js and frontend-adjacent projects. +2. Prefer `.env` only when the repo already uses it for local development and it is gitignored. +3. Treat `.env.example` as documentation only; never write real secrets there. +4. Preserve comments and unrelated keys. + +If a connection string is not directly returned in the desired form, compose it from `host`, `port`, `username`, and `password` fields from `sealos-cli database connection`. + +### 6. Verify application connectivity + +Run the project's normal verification path, not just the CLI command: + +1. Run migrations or introspection if the project has a clear command (`prisma migrate`, `drizzle-kit migrate`, `db:migrate`, `db:push`). +2. Start the app or run the smallest test that opens a DB connection. +3. If the app runs outside Sealos and cannot reach the private endpoint, ask before enabling public access: + +```bash +sealos-cli database enable-public -o json +sealos-cli database connection -o json +``` + +Disable public access after testing if it is no longer needed: + +```bash +sealos-cli database disable-public -o json +``` + +### 7. Report the result + +Summarize: + +1. Database name, type, region/workspace, and status. +2. Env file and key updated, without revealing the secret value. +3. Verification command and outcome. +4. Any public access state and follow-up action. + +## Common Tasks + +### Connect an existing project to a Sealos database + +1. Run the analyzer. +2. Inspect the env/config files it cites. +3. List existing Sealos databases. +4. Create or reuse the matching database. +5. Fetch connection details. +6. Write the expected env key. +7. Run the app's DB verification. + +### Replace a local Compose database for development + +1. Identify the app service env vars that point at `postgres`, `mysql`, `mongo`, or `redis` compose services. +2. Provision the equivalent Sealos database. +3. Update only the app's local env file, not the compose file, unless the user asks to remove the local service. +4. Keep local Compose rollback simple: the original compose service remains available. + +### Add a database to a Devbox workflow + +1. Use private database connection details when the Devbox runs in the same Sealos workspace. +2. Write env vars into the Devbox/app environment expected by the repo. +3. Restart or reload the Devbox process only after env vars are in place. + +## References + +- `scripts/analyze-project-database.mjs` - read-only project database intent analyzer. +- `references/sealos-cli-database.md` - `sealos-cli database` command contract. +- `references/env-integration.md` - safe env-file editing and connection-string mapping. diff --git a/plugins/labring/sealos-skills/skills/sealos-database/agents/openai.yaml b/plugins/labring/sealos-skills/skills/sealos-database/agents/openai.yaml new file mode 100644 index 00000000..0e8e1bb2 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-database/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sealos Database" + short_description: "Use Sealos cloud databases while developing." + default_prompt: "Use $sealos-database to create or connect a Sealos Cloud database for this project." diff --git a/plugins/labring/sealos-skills/skills/sealos-database/evals/evals.json b/plugins/labring/sealos-skills/skills/sealos-database/evals/evals.json new file mode 100644 index 00000000..f56d155c --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-database/evals/evals.json @@ -0,0 +1,57 @@ +{ + "skill_name": "sealos-database", + "evals": [ + { + "id": 0, + "prompt": "/sealos-database create a cloud Postgres database for this repo and wire DATABASE_URL", + "expected_output": "Analyzes the project, checks sealos-cli auth/workspace, lists existing databases, creates or reuses PostgreSQL, writes the local DATABASE_URL without printing secrets, and verifies the app database path.", + "files": [], + "assertions": [ + { + "name": "uses-analyzer", + "description": "Runs scripts/analyze-project-database.mjs or performs equivalent file-backed detection before choosing the database type" + }, + { + "name": "uses-sealos-cli", + "description": "Uses sealos-cli database commands instead of raw kubectl database manifests" + }, + { + "name": "protects-secrets", + "description": "Does not print passwords or full connection strings in the final answer" + } + ] + }, + { + "id": 1, + "prompt": "/sealos-database connect this app to an existing Sealos Redis database for local development", + "expected_output": "Lists existing databases, selects a matching Redis database or asks if ambiguous, fetches connection details, updates the existing Redis env key, and verifies the app's cache/queue path.", + "files": [], + "assertions": [ + { + "name": "prefers-reuse", + "description": "Checks database list before creating a new database" + }, + { + "name": "maps-env-key", + "description": "Uses REDIS_URL or the existing project-specific Redis env key" + } + ] + }, + { + "id": 2, + "prompt": "/sealos-database my local laptop cannot reach the private database endpoint", + "expected_output": "Explains that local-machine access may require public access, asks before enabling it, and recommends disabling public access after testing if no longer needed.", + "files": [], + "assertions": [ + { + "name": "asks-before-public", + "description": "Does not run enable-public without user confirmation" + }, + { + "name": "cleanup-guidance", + "description": "Mentions disable-public when public access is temporary" + } + ] + } + ] +} diff --git a/plugins/labring/sealos-skills/skills/sealos-database/references/env-integration.md b/plugins/labring/sealos-skills/skills/sealos-database/references/env-integration.md new file mode 100644 index 00000000..36305585 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-database/references/env-integration.md @@ -0,0 +1,69 @@ +# Environment Integration + +Use this reference when wiring Sealos database connection data into a development project. + +## File Choice + +Prefer the project's existing convention: + +1. `.env.local` for Next.js and similar local-only app config. +2. `.env` when the repo already uses it for local development and it is ignored by git. +3. Framework-specific files such as `.dev.vars`, `.env.development`, or `apps/*/.env.local` when the code already reads them. +4. `.env.example` only for placeholder documentation. Never write real secrets into example files. + +Before writing secrets, verify the file is ignored: + +```bash +git check-ignore .env .env.local .env.development +``` + +If the target file is tracked or not ignored, stop and choose an ignored local env file instead. + +## Env Key Mapping + +Prefer keys already used by the app. + +| Database | Common keys | +| --- | --- | +| PostgreSQL | `DATABASE_URL`, `POSTGRES_URL`, `POSTGRES_PRISMA_URL` | +| MySQL | `DATABASE_URL`, `MYSQL_URL`, `MYSQL_DATABASE_URL` | +| MongoDB | `MONGODB_URI`, `MONGO_URL`, `DATABASE_URL` | +| Redis | `REDIS_URL`, `KV_URL`, `CACHE_URL`, `QUEUE_REDIS_URL` | +| Qdrant | `QDRANT_URL`, `QDRANT_API_KEY` | +| Weaviate | `WEAVIATE_URL`, `WEAVIATE_API_KEY` | +| ClickHouse | `CLICKHOUSE_URL` | + +If multiple keys exist, update the one read by the runtime entry point or ORM config. Do not create extra aliases unless the app needs them. + +## Connection String Shapes + +Use a connection string returned by `sealos-cli database connection` when available. If only components are returned, compose the minimal expected form: + +```text +postgresql://:@:/ +mysql://:@:/ +mongodb://:@:/?authSource=admin +redis://:@:/0 +``` + +For PostgreSQL, default the database path to `postgres` unless the project explicitly expects another database name. If the app requires a non-default database, create it with the app's migration/bootstrap command or a safe one-time SQL command only after confirming the target. + +## Editing Rules + +1. Preserve comments, blank lines, and unrelated keys. +2. Replace only the selected key. +3. If the key exists and has a non-empty value, preserve the old value in chat as "replaced existing local value" without printing it. +4. Quote values only if the project's env files already use quotes or the value contains characters that the loader requires quoted. +5. Never print the full resulting connection string in the final answer. + +## Verification + +Use the project's own path: + +- Prisma: `npx prisma db pull`, `npx prisma migrate status`, or the repo's migration script. +- Drizzle: `npx drizzle-kit check`, `npx drizzle-kit migrate`, or the repo's migration script. +- Rails: `bin/rails db:prepare` or `bin/rails db:migrate`. +- Django: `python manage.py migrate --check` or `python manage.py migrate`. +- Generic Node: run the app's smallest server/test script that opens a DB connection. + +If the app runs from the user's laptop and private Sealos endpoints are unreachable, ask before enabling public access with `sealos-cli database enable-public -o json`. diff --git a/plugins/labring/sealos-skills/skills/sealos-database/references/sealos-cli-database.md b/plugins/labring/sealos-skills/skills/sealos-database/references/sealos-cli-database.md new file mode 100644 index 00000000..3a12732b --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-database/references/sealos-cli-database.md @@ -0,0 +1,152 @@ +# sealos-cli Database Reference + +Use `sealos-cli` as the execution layer for Sealos Cloud database work. It is a Node.js Commander CLI whose database commands call `dbprovider./api/v2alpha` with auth from `~/.sealos/kubeconfig`. + +## Install and Auth + +Prefer an existing binary: + +```bash +sealos-cli --version +sealos-cli whoami +``` + +Use one-off execution when the binary is missing: + +```bash +npx -y sealos-cli@latest --version +``` + +Authenticate and choose the workspace: + +```bash +sealos-cli login https://usw-1.sealos.io +sealos-cli workspace list +sealos-cli workspace switch +sealos-cli workspace current +``` + +`sealos-cli` stores auth metadata at `~/.sealos/auth.json` and the active workspace kubeconfig at `~/.sealos/kubeconfig`. Do not print or commit these files. + +## Provider Host Resolution + +Database commands use this precedence: + +1. `SEALOS_DATABASE_HOST` if set. +2. `SEALOS_REGION` if set, rewritten to `dbprovider.`. +3. Region saved by `sealos-cli login`, rewritten to `dbprovider.`. +4. Default region. + +## Read Commands + +Use JSON for automation: + +```bash +sealos-cli database list -o json +sealos-cli database versions -o json +sealos-cli database versions --type postgresql -o json +sealos-cli database get -o json +sealos-cli database connection -o json +sealos-cli database backups -o json +``` + +`database connection` may return private and public connection fields. Prefer private details for apps running inside Sealos/Devbox. Public access may be disabled by default. + +## Create and Update + +Create with conservative development resources unless project needs say otherwise: + +```bash +sealos-cli database create postgresql --name --cpu 1 --memory 1 --storage 3 --replicas 1 -o json +``` + +Supported create types: + +- `postgresql` +- `mongodb` +- `mysql` +- `apecloud-mysql` +- `redis` +- `kafka` +- `qdrant` +- `nebula` +- `weaviate` +- `milvus` +- `pulsar` +- `clickhouse` + +Useful options: + +```bash +--version +--cpu +--memory +--storage +--replicas +--termination-policy +--backup-start +--backup-type +--backup-week +--backup-hour <00-23> +--backup-minute <00-59> +--backup-save-time +--backup-save-type +--param KEY=VALUE +``` + +Update resources: + +```bash +sealos-cli database update --cpu 2 --memory 4 --storage 10 -o json +``` + +## Operations + +Non-destructive lifecycle operations: + +```bash +sealos-cli database start -o json +sealos-cli database pause -o json +sealos-cli database restart -o json +``` + +Backups: + +```bash +sealos-cli database backup --name -o json +sealos-cli database backups -o json +sealos-cli database restore --from --name -o json +``` + +Public access: + +```bash +sealos-cli database enable-public -o json +sealos-cli database disable-public -o json +``` + +Ask before enabling public access. Disable it after local-machine testing when it is no longer needed. + +Destructive commands require explicit user confirmation: + +```bash +sealos-cli database delete -o json +sealos-cli database backup-delete -o json +``` + +## Logs + +Discover log files before reading logs: + +```bash +sealos-cli database log-files --db-type postgresql --log-type runtimeLog -o json +sealos-cli database logs --db-type postgresql --log-type runtimeLog --log-path -o json +``` + +Supported log DB types are `postgresql`, `mongodb`, `mysql`, and `redis`. Supported log types are `runtimeLog`, `slowQuery`, and `errorLog`. + +## Response Handling + +Expect JSON by default. Treat operation responses with `status: "requested"` as asynchronous. Poll `database get` or `database connection` until the database status and connection fields are ready. + +Do not rely on table output for automation. Table output is only for human inspection with `-o table`. diff --git a/plugins/labring/sealos-skills/skills/sealos-database/scripts/analyze-project-database.mjs b/plugins/labring/sealos-skills/skills/sealos-database/scripts/analyze-project-database.mjs new file mode 100644 index 00000000..ea97a759 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-database/scripts/analyze-project-database.mjs @@ -0,0 +1,353 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { basename, join, resolve } from 'node:path' + +const root = resolve(process.argv[2] || process.cwd()) + +const MAX_FILE_BYTES = 1024 * 1024 +const MAX_FINDINGS_PER_KIND = 40 + +const ignoredDirs = new Set([ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.turbo', + '.venv', + 'venv', + '__pycache__', + 'coverage', + '.sealos' +]) + +const textFilePatterns = [ + /^package\.json$/, + /^pnpm-lock\.yaml$/, + /^package-lock\.json$/, + /^yarn\.lock$/, + /^bun\.lockb?$/, + /^docker-compose.*\.ya?ml$/, + /^compose.*\.ya?ml$/, + /^\.env.*$/, + /^.*\.env$/, + /^.*\.prisma$/, + /^drizzle\.config\.(ts|js|mjs|cjs)$/, + /^.*\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|kt|php|yaml|yml|json|toml)$/ +] + +const signals = [ + { + type: 'postgresql', + confidence: 4, + patterns: [ + /\bpostgres(?:ql)?:\/\//i, + /\bPOSTGRES(?:QL)?_/i, + /\bprovider\s*=\s*["']postgres(?:ql)?["']/i, + /["']postgres(?:ql)?["']\s*:\s*\{/i, + /\bpg\b/, + /drizzle-orm\/node-postgres/i, + /postgresql/i, + /psycopg/i + ] + }, + { + type: 'mongodb', + confidence: 4, + patterns: [ + /\bmongodb(?:\+srv)?:\/\//i, + /\bMONGODB_URI\b/i, + /\bMONGO_URL\b/i, + /\bmongoose\b/i, + /\bmongodb\b/i + ] + }, + { + type: 'mysql', + confidence: 3, + patterns: [ + /\bmysql:\/\//i, + /\bMYSQL_/i, + /\bmysql2\b/i, + /\bprovider\s*=\s*["']mysql["']/i, + /["']mysql["']\s*:\s*\{/i, + /\bprisma.*mysql/i + ] + }, + { + type: 'redis', + confidence: 3, + patterns: [ + /\bredis:\/\//i, + /\bREDIS_URL\b/i, + /\bioredis\b/i, + /@upstash\/redis/i, + /\bbullmq?\b/i + ] + }, + { + type: 'qdrant', + confidence: 2, + patterns: [/\bQDRANT_/i, /\bqdrant\b/i] + }, + { + type: 'weaviate', + confidence: 2, + patterns: [/\bWEAVIATE_/i, /\bweaviate\b/i] + }, + { + type: 'clickhouse', + confidence: 2, + patterns: [/\bCLICKHOUSE_/i, /\bclickhouse\b/i] + } +] + +const envKeyByType = { + postgresql: ['DATABASE_URL', 'POSTGRES_URL', 'POSTGRES_PRISMA_URL'], + mysql: ['DATABASE_URL', 'MYSQL_URL', 'MYSQL_DATABASE_URL'], + mongodb: ['MONGODB_URI', 'MONGO_URL', 'DATABASE_URL'], + redis: ['REDIS_URL', 'KV_URL', 'CACHE_URL', 'QUEUE_REDIS_URL'], + qdrant: ['QDRANT_URL'], + weaviate: ['WEAVIATE_URL'], + clickhouse: ['CLICKHOUSE_URL'] +} + +function isTextCandidate (filePath) { + const name = basename(filePath) + return textFilePatterns.some((pattern) => pattern.test(name)) +} + +function walk (dir, files = []) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return files + } + + for (const entry of entries) { + if (ignoredDirs.has(entry.name)) continue + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + walk(fullPath, files) + continue + } + if (!entry.isFile()) continue + if (!isTextCandidate(fullPath)) continue + try { + if (statSync(fullPath).size > MAX_FILE_BYTES) continue + } catch { + continue + } + files.push(fullPath) + } + + return files +} + +function safeRead (filePath) { + try { + return readFileSync(filePath, 'utf8') + } catch { + return '' + } +} + +function relative (filePath) { + return filePath.startsWith(root) ? filePath.slice(root.length + 1) : filePath +} + +function addFinding (bucket, finding) { + if (bucket.length >= MAX_FINDINGS_PER_KIND) return + bucket.push(finding) +} + +function fileWeight (filePath) { + const rel = relative(filePath) + const name = basename(filePath) + + if (/^\.env|\.env$/.test(name) || name.includes('.env.')) return 5 + if (name === 'package.json') return 4 + if (/docker-compose.*\.ya?ml$|compose.*\.ya?ml$/i.test(name)) return 3 + if (/schema\.prisma$|drizzle\.config\.(ts|js|mjs|cjs)$/.test(rel)) return 4 + if (/(^|\/)(prisma|drizzle|migrations|db\/migrations|database\/migrations)(\/|$)/.test(rel)) return 3 + if (/(^|\/)(__tests__|test|tests|spec|specs|coverage|docs?|generated)(\/|$)/i.test(rel)) return 0.2 + if (/_openapi\.json$|openapi|swagger/i.test(rel)) return 0.1 + if (/README|CHANGELOG|LICENSE|SECURITY/i.test(name)) return 0.2 + + return 1 +} + +function scanEnvFiles (files) { + const envFiles = [] + const keys = {} + + for (const filePath of files) { + const name = basename(filePath) + if (!/^\.env|\.env$/.test(name) && !name.includes('.env.')) continue + const content = safeRead(filePath) + const fileKeys = [] + for (const [index, line] of content.split(/\r?\n/).entries()) { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/) + if (!match) continue + const key = match[1] + fileKeys.push(key) + if (!keys[key]) keys[key] = [] + keys[key].push({ file: relative(filePath), line: index + 1 }) + } + envFiles.push({ file: relative(filePath), keys: fileKeys }) + } + + return { envFiles, keys } +} + +function scanPackageJson () { + const packagePath = join(root, 'package.json') + if (!existsSync(packagePath)) return null + + try { + const pkg = JSON.parse(readFileSync(packagePath, 'utf8')) + return { + packageManager: pkg.packageManager || null, + scripts: pkg.scripts || {}, + dependencies: { + ...pkg.dependencies, + ...pkg.devDependencies + } + } + } catch { + return null + } +} + +function detectMigrations (files) { + const candidates = [ + 'prisma/schema.prisma', + 'prisma/migrations', + 'drizzle', + 'migrations', + 'db/migrations', + 'database/migrations' + ] + + const found = [] + for (const candidate of candidates) { + if (existsSync(join(root, candidate))) { + found.push(candidate) + } + } + + for (const filePath of files) { + const rel = relative(filePath) + if (/drizzle\.config\.(ts|js|mjs|cjs)$/.test(rel) && !found.includes(rel)) found.push(rel) + if (/schema\.prisma$/.test(rel) && !found.includes(rel)) found.push(rel) + } + + return found +} + +function scoreFiles (files) { + const scores = {} + const findings = [] + + for (const filePath of files) { + const content = safeRead(filePath) + if (!content) continue + const weight = fileWeight(filePath) + const lines = content.split(/\r?\n/) + for (const [lineIndex, line] of lines.entries()) { + for (const signal of signals) { + for (const pattern of signal.patterns) { + if (!pattern.test(line)) continue + scores[signal.type] = (scores[signal.type] || 0) + (signal.confidence * weight) + addFinding(findings, { + type: signal.type, + file: relative(filePath), + line: lineIndex + 1, + weight, + match: pattern.source + }) + break + } + } + } + } + + return { scores, findings } +} + +function choosePrimary (scores) { + const ranked = Object.entries(scores) + .sort((a, b) => b[1] - a[1]) + .map(([type, score]) => ({ type, score: Number(score.toFixed(2)) })) + + if (ranked.length === 0) return { primary: null, ranked } + + const [first, second] = ranked + const confidence = !second ? 'high' : first.score >= second.score * 1.5 ? 'high' : 'medium' + return { primary: { ...first, confidence }, ranked } +} + +function suggestEnvTargets (primaryType, envKeys) { + if (!primaryType) return [] + const preferred = envKeyByType[primaryType] || [] + const existing = preferred.filter((key) => envKeys[key]) + return existing.length > 0 ? existing : preferred.slice(0, 1) +} + +function suggestCreateCommand (primaryType) { + const type = primaryType || 'postgresql' + const safeType = type === 'mysql' ? 'mysql' : type + return `sealos-cli database create ${safeType} --name --cpu 1 --memory 1 --storage 3 --replicas 1 -o json` +} + +if (!existsSync(root)) { + console.error(JSON.stringify({ ok: false, error: `Path does not exist: ${root}` }, null, 2)) + process.exit(1) +} + +const files = walk(root) +const { envFiles, keys: envKeys } = scanEnvFiles(files) +const packageInfo = scanPackageJson() +const migrations = detectMigrations(files) +const { scores, findings } = scoreFiles(files) +const { primary, ranked } = choosePrimary(scores) +const suggestedEnvKeys = suggestEnvTargets(primary?.type, envKeys) + +const output = { + ok: true, + project: root, + recommendation: { + databaseType: primary?.type || 'postgresql', + confidence: primary?.confidence || 'low', + reason: primary ? 'Detected project database signals.' : 'No database-specific signal found; postgresql is the default only if the user wants a new relational database.', + suggestedEnvKeys, + createCommand: suggestCreateCommand(primary?.type) + }, + existingEnv: { + files: envFiles, + databaseKeys: Object.fromEntries( + Object.entries(envKeys) + .filter(([key]) => /DATABASE|POSTGRES|MYSQL|MONGO|REDIS|QDRANT|WEAVIATE|CLICKHOUSE|CACHE|QUEUE|KV/.test(key)) + ) + }, + package: packageInfo + ? { + packageManager: packageInfo.packageManager, + databaseDependencies: Object.keys(packageInfo.dependencies || {}).filter((name) => + /(prisma|drizzle|typeorm|sequelize|mongoose|mongodb|pg$|mysql|mysql2|redis|ioredis|upstash|qdrant|weaviate|clickhouse)/i.test(name) + ), + migrationScripts: Object.fromEntries( + Object.entries(packageInfo.scripts || {}).filter(([name, value]) => + /(db|database|migrate|migration|prisma|drizzle)/i.test(`${name} ${value}`) + ) + ) + } + : null, + migrations, + rankedSignals: ranked, + findings +} + +console.log(JSON.stringify(output, null, 2)) diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/SKILL.md b/plugins/labring/sealos-skills/skills/sealos-deploy/SKILL.md new file mode 100644 index 00000000..e46d764c --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/SKILL.md @@ -0,0 +1,194 @@ +--- +name: sealos-deploy +description: Deploy any GitHub project to Sealos Cloud in one command. Assesses readiness, generates Dockerfile, builds image, creates Sealos template, and deploys — fully automated. Use when user says "deploy to sealos", "deploy this project", "deploy to cloud", "deploy this repo", mentions Sealos deployment, wants to deploy a GitHub URL or local project to a cloud platform, or asks about one-click deployment. Also triggers on "/sealos-deploy". +compatibility: Sealos auth/workspace are required for deploys. Docker, buildx, and gh CLI are required only when the selected path needs local build/push. git is required when cloning from a GitHub URL or when git metadata is needed. Node.js 18+ and Python 3.8+ remain optional accelerators. +metadata: + author: labring +--- + +# Sealos Deploy + +Deploy any GitHub project to Sealos Cloud — from source code to running application, one command. + +## kubectl Safety Rules (all phases) + +All kubectl commands MUST use the Sealos kubeconfig: +``` +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify +``` + +System tool installation requires user confirmation. If `docker`, `gh`, or `kubectl` is missing and the skill can install it for the current platform, ask first and only run the install command after the user explicitly replies `y`. + +**`kubectl delete` requires user confirmation.** Before deleting any resource (deployment, service, ingress, PVC, database, etc.), always ask: +``` +WARNING: About to delete /. This data cannot be recovered. Confirm? (y/n) +``` +Only proceed after user confirms. This applies even if the pipeline logic suggests deletion — always ask first. + +## Usage + +``` +/sealos-deploy +/sealos-deploy # deploy current project +/sealos-deploy +``` + +## Quick Start + +Execute the modules in order: + +1. `modules/preflight.md` — Environment checks & Sealos auth +2. `modules/pipeline.md` — Full deployment pipeline (Phase 1–6) + +## Logging + +Every run MUST write a log file at `~/.sealos/logs/deploy-.log`. + +**At the very start of execution**, create the log file **once**: +```bash +mkdir -p ~/.sealos/logs +LOG_FILE=~/.sealos/logs/deploy-$(date +%Y%m%d-%H%M%S).log +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy started" > "$LOG_FILE" +``` + +**Important: create the log file ONLY ONCE at the start. All subsequent writes MUST append (`>>`) to this same `$LOG_FILE`. Do NOT create a second log file.** + +**At each phase boundary**, append a log entry to the same file with Bash `>>`: +``` +[2026-03-05 14:30:01] === Phase 0: Preflight === +[2026-03-05 14:30:01] Docker: ✓ 27.5.1 +[2026-03-05 14:30:01] Node.js: ✓ 22.12.0 +[2026-03-05 14:30:02] Sealos auth: ✓ (region: ) +[2026-03-05 14:30:02] Project: /Users/dev/myapp (github: https://github.com/owner/repo) + +[2026-03-05 14:30:03] === Phase 1: Assess === +[2026-03-05 14:30:03] Score: 9/12 (good) +[2026-03-05 14:30:03] Language: python, Framework: fastapi, Port: 8000 +[2026-03-05 14:30:03] Decision: CONTINUE + +[2026-03-05 14:30:04] === Phase 2: Detect Image === +[2026-03-05 14:30:05] Docker Hub: owner/repo:latest (arm64 only, no amd64) +[2026-03-05 14:30:05] GHCR: not found +[2026-03-05 14:30:05] Decision: no amd64 image → continue to Phase 3 + +[2026-03-05 14:30:06] === Phase 3: Dockerfile === +[2026-03-05 14:30:06] Existing Dockerfile: none +[2026-03-05 14:30:07] Generated: python-fastapi template, port 8000 + +[2026-03-05 14:30:08] === Phase 4: Build & Push === +[2026-03-05 14:30:08] Registry: ghcr (auto-detected via gh CLI) +[2026-03-05 14:30:30] Build: ✓ ghcr.io/zhujingyang/repo:20260305-143022 +[2026-03-05 14:30:32] GHCR pullability: private package detected — deploy will auto-create image pull Secret from gh CLI +[2026-03-05 14:30:33] IMAGE_REF=ghcr.io/zhujingyang/repo:20260305-143022 + +[2026-03-05 14:30:34] === Phase 5: Template === +[2026-03-05 14:30:35] Output: .sealos/template/index.yaml + +[2026-03-05 14:30:36] === Phase 6: Deploy === +[2026-03-05 14:30:36] Deploy URL: https://template.gzg.sealos.run/api/v2alpha/templates/raw +[2026-03-05 14:30:38] Status: 201 — deployed successfully +[2026-03-05 14:30:38] === DONE === +``` + +**On error**, log the error details before stopping: +``` +[2026-03-05 14:30:10] === ERROR === +[2026-03-05 14:30:10] Phase: 4 (Build & Push) +[2026-03-05 14:30:10] Error: docker buildx build failed — "npm ERR! Missing script: build" +[2026-03-05 14:30:10] Retry: 1/3 +``` + +**At the very end**, tell the user where the log is: +``` +Log saved to: ~/.sealos/logs/deploy-20260305-143001.log +``` + +## Scripts + +Located in `scripts/` within this skill directory (`/scripts/`): + +| Script | Usage | Purpose | +|--------|-------|---------| +| `score-model.mjs` | `node score-model.mjs ` | Deterministic readiness scoring (0-12) | +| `validate-artifacts.mjs` | `node validate-artifacts.mjs --dir ` | Validate `.sealos` JSON artifacts against enforced schemas | +| `detect-image.mjs` | `node detect-image.mjs [work-dir]` or `node detect-image.mjs ` | Detect existing Docker/GHCR images | +| `build-push.mjs` | `node build-push.mjs [--registry ghcr\|dockerhub] [--user ]` | Build amd64 image & push to the selected registry (Docker Hub path assumes a public image at deploy time; omitting `--registry` keeps auto-detect behavior) | +| `ensure-image-pull-secret.mjs` | `node ensure-image-pull-secret.mjs [deployment-name]` | Create/update app-scoped GHCR pull Secret and optionally patch an existing Deployment to reference it | +| `gh-refresh-scopes.mjs` | `node gh-refresh-scopes.mjs write:packages` | Refresh GHCR package access in the current TTY; `write:packages` is sufficient for both push and private pull in this workflow | +| `deploy-template.mjs` | `node deploy-template.mjs [--dry-run] [--args-json '{"KEY":"value"}'\|--args-file ]` | Resolve the current region from `~/.sealos/auth.json`, build the correct Template API URL, and post a local template YAML | +| `sealos-auth.mjs` | `node sealos-auth.mjs check\|login\|list\|switch` | Sealos Cloud authentication & workspace switching | + +All scripts output JSON. Run via Bash and parse the result. + +## Internal Skill Dependencies + +This skill references knowledge files from co-installed internal skills. These are **not** user-facing — they are loaded on-demand during specific phases. + +`` refers to the directory containing this `SKILL.md`. Sibling skills are at `/../`: + +``` +/../ +├── sealos-deploy/ ← this skill (user entry point) = +├── dockerfile-skill/ ← Phase 3: Dockerfile generation knowledge +├── cloud-native-readiness/ ← Phase 1: assessment criteria +└── docker-to-sealos/ ← Phase 5: Sealos template rules +``` + +Paths used in pipeline.md follow the pattern: +``` +/../dockerfile-skill/knowledge/error-patterns.md +/../dockerfile-skill/templates/.dockerfile +/../docker-to-sealos/references/sealos-specs.md +``` + +## Phase Overview + +| Phase | Action | Skip When | +|-------|--------|-----------| +| 0 — Preflight | Capability scan, path-specific warnings, Sealos auth | Initial blockers resolved | +| 1 — Assess | Clone repo (or use current project), analyze deployability | Score too low → stop | +| 2 — Detect | Find existing image (Docker Hub / GHCR / README) | Found → jump to Phase 5 | +| 3 — Dockerfile | Generate Dockerfile if missing | Already has one → skip | +| 4 — Build & Push | `docker buildx` → GHCR (auto via gh CLI) or Docker Hub (fallback) | — | +| 5 — Template | Generate Sealos application template | — | +| 5.5 — Configure | Guide user through app env vars and inputs | No inputs needed | +| 6 — Deploy | Deploy template to Sealos Cloud | — | + +## Decision Flow + +``` +Input (GitHub URL / local path) + │ + ▼ +[Phase 0] Preflight ── fail → guide user to fix and STOP + │ pass + ▼ +[Phase 1] Assess ── not suitable → STOP with reason + │ suitable + ▼ +[Phase 2] Detect existing image + │ + ├── found (amd64) ────────────────────┐ + │ │ + ▼ │ +[Phase 3] Dockerfile (generate/reuse) │ + │ │ + ▼ │ +[Phase 4] Build & Push to registry │ + │ │ + ◄─────────────────────────────────────┘ + │ + ▼ +[Phase 5] Generate Sealos Template + │ + ▼ +[Phase 5.5] Configure ── present env vars → ask user for inputs → confirm + │ + ▼ +[Phase 6] Deploy to Sealos Cloud ── 401 → re-auth + │ 409 → instance exists + ▼ +Done — app deployed ✓ +``` + +**Execution rule:** Phase 1 must never start while Phase 0 still has unresolved entry blockers. Docker, `gh`, builder, and registry failures must be reported early, but only become hard blockers if the run later requires local build/push. diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/config.json b/plugins/labring/sealos-skills/skills/sealos-deploy/config.json new file mode 100644 index 00000000..86de9598 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/config.json @@ -0,0 +1,10 @@ +{ + "client_id": "af993c98-d19d-4bdc-b338-79b80dc4f8bf", + "default_region": "https://usw-1.sealos.io", + "regions": [ + "https://usw-1.sealos.io", + "https://gzg.sealos.run", + "https://bja.sealos.run", + "https://hzh.sealos.run" + ] +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/evals/benchmark.json b/plugins/labring/sealos-skills/skills/sealos-deploy/evals/benchmark.json new file mode 100644 index 00000000..174e9802 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/evals/benchmark.json @@ -0,0 +1,489 @@ +{ + "metadata": { + "skill_name": "sealos-deploy", + "skill_path": "/Users/jingyang/zjy365/demo/github-pack/seakills/skills/sealos-deploy", + "executor_model": "claude-opus-4-6", + "analyzer_model": "claude-opus-4-6", + "timestamp": "2026-03-12T06:00:00Z", + "evals_run": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + "runs_per_configuration": 1 + }, + "runs": [ + { + "eval_id": 0, + "eval_name": "web-app-deploy", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 5, + "failed": 0, + "total": 5, + "time_seconds": 269.8, + "tokens": 64653, + "tool_calls": 48, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-nodejs", "passed": true, "evidence": "Report states 'Language: Node.js' and identifies Express.js + Socket.IO stack"}, + {"text": "score-above-4", "passed": true, "evidence": "Score is 9/12 (Good), well above 4 threshold"}, + {"text": "detects-external-db", "passed": true, "evidence": "Report identifies 'External database detected (PostgreSQL/MySQL/MongoDB)' and default SQLite"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with 6-dimension scoring table, signals summary, Docker readiness section"}, + {"text": "creates-log-file", "passed": true, "evidence": "Deploy log file created at ~/.sealos/logs/deploy-20260312-102318.log"} + ], + "notes": [] + }, + { + "eval_id": 0, + "eval_name": "web-app-deploy", + "configuration": "without_skill", + "run_number": 1, + "result": { + "pass_rate": 0.8, + "passed": 4, + "failed": 1, + "total": 5, + "time_seconds": 161.4, + "tokens": 54511, + "tool_calls": 35, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-nodejs", "passed": true, "evidence": "Report states 'Runtime: Node.js >= 20.4.0'"}, + {"text": "score-above-4", "passed": true, "evidence": "Score is 8/12"}, + {"text": "detects-external-db", "passed": true, "evidence": "Identifies SQLite and MariaDB"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with scoring criteria, dimension table"}, + {"text": "creates-log-file", "passed": false, "evidence": "No deploy log file created (no skill instructions)"} + ], + "notes": [] + }, + { + "eval_id": 1, + "eval_name": "cli-tool-reject", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 0.75, + "passed": 3, + "failed": 1, + "total": 4, + "time_seconds": 256.4, + "tokens": 63069, + "tool_calls": 39, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-cli-tool", "passed": true, "evidence": "Report states 'bat is a command-line utility (CLI tool)'"}, + {"text": "score-below-4", "passed": false, "evidence": "Score is 4/12 (not < 4). score-model.mjs gives Rust binaries 2/2 for scalability and startup by default"}, + {"text": "recommends-stop", "passed": true, "evidence": "Decision: STOP. AI correctly overrides with CLI tool stop condition"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with dimension breakdown, signal detection, STOP rationale"} + ], + "notes": ["score-model.mjs inflates Rust CLI tools to 4/12 by giving 2/2 for scalability and startup to all compiled binaries"] + }, + { + "eval_id": 1, + "eval_name": "cli-tool-reject", + "configuration": "without_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 139.9, + "tokens": 51363, + "tool_calls": 33, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-cli-tool", "passed": true, "evidence": "Report identifies 'short-lived, interactive command-line tool'"}, + {"text": "score-below-4", "passed": true, "evidence": "Score is 0/12, all dimensions inapplicable"}, + {"text": "recommends-stop", "passed": true, "evidence": "Recommendation: REJECT"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with dimension scoring, architecture analysis"} + ], + "notes": [] + }, + { + "eval_id": 2, + "eval_name": "current-project", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 233.4, + "tokens": 55156, + "tool_calls": 37, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-nextjs", "passed": true, "evidence": "Report identifies a Next.js landing page in a subdirectory"}, + {"text": "gives-score", "passed": true, "evidence": "Score is 5/12, with an adjusted estimate after inspecting the subdirectory app"}, + {"text": "identifies-project-structure", "passed": true, "evidence": "Report identifies a mixed repository with skills plus a landing page"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with dimension table, signal detection, anti-pattern check"} + ], + "notes": ["score-model.mjs scans repo root and can miss Dockerfiles in subdirectories, which underestimates readiness"] + }, + { + "eval_id": 2, + "eval_name": "current-project", + "configuration": "without_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 167.2, + "tokens": 35611, + "tool_calls": 45, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-nextjs", "passed": true, "evidence": "Identifies a Next.js 16 landing page in a subdirectory"}, + {"text": "gives-score", "passed": true, "evidence": "Score is 9.5/12"}, + {"text": "identifies-project-structure", "passed": true, "evidence": "Identifies a two-part project with skills plus a landing page"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with scoring criteria, dimension tables, Dockerfile analysis"} + ], + "notes": [] + }, + { + "eval_id": 3, + "eval_name": "go-web-app", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 244.1, + "tokens": 56861, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-go", "passed": true, "evidence": "Report identifies Go with Gin framework"}, + {"text": "score-above-4", "passed": true, "evidence": "Score 11/12 (Excellent)"}, + {"text": "identifies-http-server", "passed": true, "evidence": "Identifies REST API + WebDAV on ports 5244/5245"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with dimensions, signals, env vars"} + ], + "notes": [] + }, + { + "eval_id": 3, + "eval_name": "go-web-app", + "configuration": "without_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 170.7, + "tokens": 57018, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-go", "passed": true, "evidence": "Identifies Go language"}, + {"text": "score-above-4", "passed": true, "evidence": "Score 10/12"}, + {"text": "identifies-http-server", "passed": true, "evidence": "Identifies web application with Docker images"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with dimensions and deployment recommendations"} + ], + "notes": [] + }, + { + "eval_id": 4, + "eval_name": "nextjs-monorepo", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 246.5, + "tokens": 68317, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-nextjs", "passed": true, "evidence": "Identifies Next.js + Hono + React 19"}, + {"text": "score-above-4", "passed": true, "evidence": "Score 12/12 (Excellent)"}, + {"text": "detects-monorepo-or-workspace", "passed": true, "evidence": "Identifies pnpm monorepo structure"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Detailed report with infrastructure dependencies and architecture"} + ], + "notes": [] + }, + { + "eval_id": 4, + "eval_name": "nextjs-monorepo", + "configuration": "without_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 186.4, + "tokens": 69369, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-nextjs", "passed": true, "evidence": "Identifies Next.js framework"}, + {"text": "score-above-4", "passed": true, "evidence": "Score 10/12"}, + {"text": "detects-monorepo-or-workspace", "passed": true, "evidence": "Identifies complex build with multiple external services"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Detailed report with dimensions and deployment analysis"} + ], + "notes": [] + }, + { + "eval_id": 5, + "eval_name": "electron-desktop", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 210.3, + "tokens": 50101, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-desktop-app", "passed": true, "evidence": "Identifies Electron desktop GUI app with BrowserWindow, ipcMain, dialog"}, + {"text": "score-below-4", "passed": true, "evidence": "Score 1/12 (Poor), below threshold"}, + {"text": "recommends-stop", "passed": true, "evidence": "Decision: STOP, score below 4"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Report with scoring, disqualifying patterns, alternative recommendation"} + ], + "notes": [] + }, + { + "eval_id": 5, + "eval_name": "electron-desktop", + "configuration": "without_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 155.6, + "tokens": 37042, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-desktop-app", "passed": true, "evidence": "Identifies Electron desktop GUI application"}, + {"text": "score-below-4", "passed": true, "evidence": "Score 1/12"}, + {"text": "recommends-stop", "passed": true, "evidence": "NOT DEPLOYABLE"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured assessment report"} + ], + "notes": [] + }, + { + "eval_id": 6, + "eval_name": "rust-web-docker", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 219.9, + "tokens": 71335, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-rust", "passed": true, "evidence": "Identifies Rust with Rocket web framework"}, + {"text": "score-above-4", "passed": true, "evidence": "Score 12/12 (Excellent, maximum)"}, + {"text": "identifies-web-service", "passed": true, "evidence": "Identifies REST API on port 80 with /alive health check"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Detailed report with architecture analysis and env vars"} + ], + "notes": [] + }, + { + "eval_id": 6, + "eval_name": "rust-web-docker", + "configuration": "without_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 154.3, + "tokens": 55049, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-rust", "passed": true, "evidence": "Identifies Rust language"}, + {"text": "score-above-4", "passed": true, "evidence": "Score 12/12 (Excellent)"}, + {"text": "identifies-web-service", "passed": true, "evidence": "Identifies REST API with health checks"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Comprehensive assessment report"} + ], + "notes": [] + }, + { + "eval_id": 7, + "eval_name": "python-library", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 214.9, + "tokens": 52427, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-library", "passed": true, "evidence": "Identifies Flask as Python library/framework published to PyPI, not deployable service"}, + {"text": "score-below-7", "passed": true, "evidence": "Score 6/12 (Fair) from script, AI correctly identifies as library"}, + {"text": "explains-not-standalone", "passed": true, "evidence": "Explains repository is framework source code with no application entry point"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with dimensions and STOP recommendation"} + ], + "notes": ["score-model.mjs gives Flask framework source code 6/12 (false positive). AI assessment layer correctly overrides this and applies STOP condition."] + }, + { + "eval_id": 7, + "eval_name": "python-library", + "configuration": "without_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 160.9, + "tokens": 59630, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-library", "passed": true, "evidence": "Identifies Flask as web framework (library), not standalone deployable application"}, + {"text": "score-below-7", "passed": true, "evidence": "Score 2/12 (Poor)"}, + {"text": "explains-not-standalone", "passed": true, "evidence": "Explains containerizing this repository would be meaningless"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with dimensions"} + ], + "notes": [] + }, + { + "eval_id": 8, + "eval_name": "python-fastapi", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 332.3, + "tokens": 79303, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-python", "passed": true, "evidence": "Identifies Python with FastAPI framework"}, + {"text": "score-above-4", "passed": true, "evidence": "Raw 5/12, AI corrected to 10/12 (docker/ subdir issue)"}, + {"text": "detects-external-db", "passed": true, "evidence": "Detects PostgreSQL support via psycopg2-binary"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Full report with scoring corrections and env var classification"} + ], + "notes": ["score-model.mjs misses Dockerfile in docker/ subdirectory, scoring 5/12 raw. AI assessment layer detects this limitation and corrects to 10/12."] + }, + { + "eval_id": 8, + "eval_name": "python-fastapi", + "configuration": "without_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 175.0, + "tokens": 58344, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-python", "passed": true, "evidence": "Identifies Python FastAPI application"}, + {"text": "score-above-4", "passed": true, "evidence": "Score 11/12"}, + {"text": "detects-external-db", "passed": true, "evidence": "Identifies PostgreSQL database dependency"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Detailed assessment with Docker analysis"} + ], + "notes": [] + }, + { + "eval_id": 9, + "eval_name": "java-springboot", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 202.6, + "tokens": 54979, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-java", "passed": true, "evidence": "Identifies Java 17+, Spring Boot 4.0.3"}, + {"text": "score-above-4", "passed": true, "evidence": "Score 7/12 (Good)"}, + {"text": "identifies-web-service", "passed": true, "evidence": "Identifies web app on port 8080 with Actuator endpoints"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Structured report with scoring and env var classification"} + ], + "notes": [] + }, + { + "eval_id": 9, + "eval_name": "java-springboot", + "configuration": "without_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 145.7, + "tokens": 44745, + "errors": 0 + }, + "expectations": [ + {"text": "identifies-java", "passed": true, "evidence": "Identifies Java Spring Boot"}, + {"text": "score-above-4", "passed": true, "evidence": "Score 10/12"}, + {"text": "identifies-web-service", "passed": true, "evidence": "Standard HTTP on port 8080"}, + {"text": "produces-structured-report", "passed": true, "evidence": "Detailed report with K8s readiness analysis"} + ], + "notes": [] + } + ], + "run_summary": { + "with_skill": { + "pass_rate": {"mean": 0.975, "stddev": 0.075, "min": 0.75, "max": 1.0}, + "time_seconds": {"mean": 243.0, "stddev": 36.1, "min": 202.6, "max": 332.3}, + "tokens": {"mean": 61620, "stddev": 8882, "min": 50101, "max": 79303} + }, + "without_skill": { + "pass_rate": {"mean": 0.980, "stddev": 0.060, "min": 0.8, "max": 1.0}, + "time_seconds": {"mean": 161.7, "stddev": 13.1, "min": 139.9, "max": 186.4}, + "tokens": {"mean": 52268, "stddev": 9928, "min": 35611, "max": 69369} + }, + "delta": { + "pass_rate": "-0.005", + "time_seconds": "+81.3", + "tokens": "+9352" + } + }, + "analyst_notes": [ + "DISCRIMINATING ASSERTIONS: 'creates-log-file' (eval-0) is skill-specific and always fails without_skill — intentional. 'score-below-4' (eval-1 cli-tool-reject) fails for with_skill due to score-model.mjs inflating Rust binary scores — a known limitation.", + "SCORE-MODEL.MJS BUG #1 (Compiled binaries): Gives all compiled binaries (Rust/Go) 2/2 for scalability and startup regardless of whether they run an HTTP server. This inflates bat CLI from expected 0→4/12. The AI assessment layer correctly catches this and applies STOP via CLI tool pattern detection.", + "SCORE-MODEL.MJS BUG #2 (Subdirectory blind spot): Scans repo root only. Projects with Dockerfile/package.json in subdirectories get deflated scores: mealie (docker/ → raw 5/12 vs actual 10/12), while root-level projects like spring-petclinic are unaffected. AI correctly compensates when it inspects subdirectories.", + "SCORE-MODEL.MJS BUG #3 (Library false positive): Flask framework source code scores 6/12 (above CONTINUE threshold) because it has Python files and test structure. AI correctly identifies the absence of application entry point and applies STOP. Without-skill baseline scores it a correct 2/12.", + "COST TRADEOFF: With-skill runs average 81.3s slower and use 9352 more tokens (+18%). The overhead comes from reading skill files (~30KB), running score-model.mjs, and writing structured artifacts (context.json, deploy log). The payoff is deployment-specific artifacts and consistent structured output format.", + "NON-DISCRIMINATING ASSERTIONS: Most assertions (identifies-*, score-*, produces-structured-report) pass in both configurations across 9/10 evals. The skill's differentiated value shows in: (a) structured artifact output, (b) AI score correction via assessment layer, (c) consistent STOP logic for edge cases.", + "REJECTION ACCURACY: All 3 non-deployable cases (bat CLI, drawio desktop, Flask library) correctly identified by both configurations. With-skill provides more explicit STOP rationale and structured documentation of the rejection reason.", + "NOTABLE HIGH SCORERS: lobe-chat (Next.js monorepo, 12/12), vaultwarden (Rust+Docker, 12/12), alist (Go web, 11/12) — all correctly assessed as excellent K8s candidates.", + "OVERALL: Both with and without skill pass 97.5-98% of assertions. With-skill consistently provides deployment artifacts, structured logging, and compensates for score-model.mjs blind spots. The primary iteration target should be fixing score-model.mjs subdirectory scanning." + ] +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/evals/evals.json b/plugins/labring/sealos-skills/skills/sealos-deploy/evals/evals.json new file mode 100644 index 00000000..520d6200 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/evals/evals.json @@ -0,0 +1,126 @@ +{ + "skill_name": "sealos-deploy", + "evals": [ + { + "id": 0, + "prompt": "I want to deploy uptime-kuma to Sealos, GitHub: https://github.com/louislam/uptime-kuma", + "expected_output": "High readiness score (7+), identifies Node.js project with external services, recommends proceeding with deployment", + "files": [], + "assertions": [ + {"name": "identifies-nodejs", "description": "Correctly identifies the project as Node.js"}, + {"name": "score-above-4", "description": "Readiness score is >= 4 (deployable threshold)"}, + {"name": "detects-external-db", "description": "Detects external database dependency (SQLite/MariaDB)"}, + {"name": "produces-structured-report", "description": "Output contains a structured assessment report with dimensions and scores"}, + {"name": "creates-log-file", "description": "Creates a deploy log file at ~/.sealos/logs/"} + ] + }, + { + "id": 1, + "prompt": "/sealos-deploy https://github.com/sharkdp/bat", + "expected_output": "Low readiness score (0-3), identifies as CLI tool not suitable for cloud deployment, recommends STOP", + "files": [], + "assertions": [ + {"name": "identifies-cli-tool", "description": "Correctly identifies bat as a CLI tool / non-web application"}, + {"name": "score-below-4", "description": "Readiness score is < 4 (not suitable for cloud deployment)"}, + {"name": "recommends-stop", "description": "Explicitly recommends NOT deploying / STOP"}, + {"name": "produces-structured-report", "description": "Output contains a structured assessment report"} + ] + }, + { + "id": 2, + "prompt": "/sealos-deploy", + "expected_output": "Low readiness score (0-3), identifies the current repository as a skills pack or tooling repo rather than a deployable web service, recommends STOP", + "files": [], + "assertions": [ + {"name": "identifies-skill-pack", "description": "Identifies the current repository as a skills pack or tooling repo rather than a standalone web app"}, + {"name": "score-below-4", "description": "Readiness score is < 4 (not suitable for direct cloud deployment)"}, + {"name": "recommends-stop", "description": "Explicitly recommends NOT deploying / STOP"}, + {"name": "produces-structured-report", "description": "Output contains a structured assessment report"} + ] + }, + { + "id": 3, + "prompt": "Help me deploy alist to Sealos, project URL: https://github.com/alist-org/alist", + "expected_output": "High readiness score (8+), identifies Go web application with REST API and WebDAV server, recommends CONTINUE", + "files": [], + "assertions": [ + {"name": "identifies-go", "description": "Correctly identifies the project as Go language"}, + {"name": "score-above-4", "description": "Readiness score is >= 4 (deployable threshold)"}, + {"name": "identifies-http-server", "description": "Identifies this as an HTTP server / web service"}, + {"name": "produces-structured-report", "description": "Output contains a structured assessment report"} + ] + }, + { + "id": 4, + "prompt": "I want to deploy lobe-chat to Sealos: https://github.com/lobehub/lobe-chat", + "expected_output": "High readiness score (10+), identifies Next.js monorepo with pnpm workspaces, recommends CONTINUE", + "files": [], + "assertions": [ + {"name": "identifies-nextjs", "description": "Correctly identifies the project as Next.js"}, + {"name": "score-above-4", "description": "Readiness score is >= 4 (deployable threshold)"}, + {"name": "detects-monorepo-or-workspace", "description": "Detects pnpm monorepo / workspace structure"}, + {"name": "produces-structured-report", "description": "Output contains a structured assessment report"} + ] + }, + { + "id": 5, + "prompt": "/sealos-deploy https://github.com/jgraph/drawio-desktop", + "expected_output": "Very low readiness score (0-1), identifies as Electron desktop GUI app unsuitable for cloud deployment, recommends STOP", + "files": [], + "assertions": [ + {"name": "identifies-desktop-app", "description": "Correctly identifies drawio-desktop as an Electron / desktop GUI application"}, + {"name": "score-below-4", "description": "Readiness score is < 4 (not suitable for cloud deployment)"}, + {"name": "recommends-stop", "description": "Explicitly recommends NOT deploying / STOP"}, + {"name": "produces-structured-report", "description": "Output contains a structured assessment report"} + ] + }, + { + "id": 6, + "prompt": "Deploy vaultwarden to Sealos: https://github.com/dani-garcia/vaultwarden", + "expected_output": "Maximum readiness score (12/12), identifies Rust web application with Rocket framework and existing Dockerfile, recommends CONTINUE", + "files": [], + "assertions": [ + {"name": "identifies-rust", "description": "Correctly identifies the project as Rust language"}, + {"name": "score-above-4", "description": "Readiness score is >= 4 (deployable threshold)"}, + {"name": "identifies-web-service", "description": "Identifies this as a web service / HTTP server"}, + {"name": "produces-structured-report", "description": "Output contains a structured assessment report"} + ] + }, + { + "id": 7, + "prompt": "/sealos-deploy https://github.com/pallets/flask", + "expected_output": "Low score or STOP, correctly identifies Flask as a Python web framework library not a deployable application", + "files": [], + "assertions": [ + {"name": "identifies-library", "description": "Correctly identifies Flask as a framework/library, not a standalone deployable app"}, + {"name": "score-below-7", "description": "Readiness score is < 7 (library should not be considered highly deployable)"}, + {"name": "explains-not-standalone", "description": "Explains why this repository is not directly deployable as a service"}, + {"name": "produces-structured-report", "description": "Output contains a structured assessment report"} + ] + }, + { + "id": 8, + "prompt": "Help me deploy mealie (home recipe management): https://github.com/mealie-recipes/mealie", + "expected_output": "High readiness score (8+), identifies Python FastAPI application with PostgreSQL support, recommends CONTINUE", + "files": [], + "assertions": [ + {"name": "identifies-python", "description": "Correctly identifies the project as Python"}, + {"name": "score-above-4", "description": "Readiness score is >= 4 (deployable threshold)"}, + {"name": "detects-external-db", "description": "Detects PostgreSQL database dependency"}, + {"name": "produces-structured-report", "description": "Output contains a structured assessment report"} + ] + }, + { + "id": 9, + "prompt": "/sealos-deploy https://github.com/spring-projects/spring-petclinic", + "expected_output": "Good readiness score (6+), identifies Java Spring Boot application with port 8080 and Actuator endpoints, recommends CONTINUE", + "files": [], + "assertions": [ + {"name": "identifies-java", "description": "Correctly identifies the project as Java / Spring Boot"}, + {"name": "score-above-4", "description": "Readiness score is >= 4 (deployable threshold)"}, + {"name": "identifies-web-service", "description": "Identifies this as a web application on port 8080"}, + {"name": "produces-structured-report", "description": "Output contains a structured assessment report"} + ] + } + ] +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/knowledge/lessons-learned.md b/plugins/labring/sealos-skills/skills/sealos-deploy/knowledge/lessons-learned.md new file mode 100644 index 00000000..559c1248 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/knowledge/lessons-learned.md @@ -0,0 +1,109 @@ +# Lessons Learned from Real Deployments + +This document captures patterns and solutions from actual Sealos deployment experiences to prevent repeated mistakes. + +--- + +## Case Study: EverShop (Public URL + Image Detection) + +**Project**: EverShop - Node.js e-commerce platform using node-config +**GitHub**: `evershopcommerce/evershop` +**Issues Encountered**: 2 (public URL misconfiguration, image detection miss) + +### Issue 1: Hardcoded localhost Base URL + +- **Symptom**: App deployed successfully but all frontend API calls failed (404/CORS errors) +- **Root Cause**: App uses node-config with `getConfig('shop.homeUrl', 'http://localhost:3000')` — when no config override exists, all generated URLs point to localhost +- **Detection Signal**: `packages/evershop/src/lib/util/getBaseUrl.ts` contains fallback to `http://localhost:3000` +- **Fix**: Created ConfigMap with `config/default.json` containing `{"shop":{"homeUrl":"https://"}}`, mounted via `subPath` to avoid overwriting other config files +- **Generalized Pattern**: **Public URL via file-based config** — many apps (especially Node.js with node-config, PHP with config files) read their public URL from config files rather than env vars. When `localhost` fallback is detected in source code, a ConfigMap override is required. +- **Status**: Pattern added to `conversion-mappings.md` (Strategy B: ConfigMap) + +### Issue 2: Docker Hub Image Not Found + +- **Symptom**: `detect-image.mjs` returned `{ "found": false }`, triggering unnecessary Docker build +- **Root Cause**: Script only checked `/` (i.e., `evershopcommerce/evershop`), but official Docker image is at `evershop/evershop` +- **Detection Signal**: Docker Hub namespace differs from GitHub org — common when project name is shorter than org name +- **Fix**: Added fallback check for `/` pattern in `detect-image.mjs` +- **Other Known Examples**: + - GitHub `nextcloud/server` → Docker Hub `nextcloud/nextcloud` + - GitHub `gogs/gogs` → Docker Hub `gogs/gogs` (same, but org ≠ repo in other cases) +- **Status**: Fallback added to `detect-image.mjs` + +### Generalized Lessons + +1. **Public URL Detection is Critical**: Always scan source code for `localhost` fallback patterns during Phase 5.2. Missing this causes subtle runtime failures (app loads but API calls fail). +2. **Image Detection Needs Multiple Strategies**: Don't assume Docker Hub namespace matches GitHub org. Check `/` as fallback. +3. **Config File Overrides via ConfigMap**: When an app uses file-based config (not env vars) for its public URL, use a ConfigMap with `subPath` mount to inject only the needed override without replacing the entire config directory. + +--- + +## Consolidated Patterns + +### GHCR Push Succeeds but Cluster Pull Fails (Prevents `ImagePullBackOff`) + +```yaml +detection: + trigger: + - "Phase 4 built a ghcr.io//: image locally" + - "Deployment later stalls with ImagePullBackOff or ErrImagePull" + root_causes: + - "GitHub Container Registry package visibility is still private" + - "Cluster has no imagePullSecret for ghcr.io" + +decision: + if_local_gh_cli_is_available: + require: "create or refresh the namespace image pull Secret automatically before deploy/update" + else: + fallback: "package must be public, or the operator must provide registry pull credentials another way" + skip_when: + - "Phase 2 reused an existing public image" + +verification: + visibility_check: "gh api /user/packages/container/ -q .visibility" + anonymous_pull_check: "GET ghcr token, then HEAD/GET manifest from ghcr.io/v2/.../manifests/" + +fixes: + preferred: "create/update the app-scoped imagePullSecret from gh auth token during deploy" + fallback_1: "make the GHCR package public" + fallback_2: "push to Docker Hub instead" +``` + +### Public URL Misconfiguration (Prevents Runtime API Failures) + +```yaml +detection: + # Scan source code for these patterns + env_var_patterns: + - "BASE_URL" + - "SITE_URL" + - "APP_URL" + - "NEXTAUTH_URL" + - "PUBLIC_URL" + - "EXTERNAL_URL" + config_file_patterns: + - "getConfig(.*[Uu]rl" + - "homeUrl" + - "baseUrl" + - "siteUrl" + - "http://localhost" + + # Decision + strategy: + env_var_supported: "Strategy A — add env var with public URL" + config_file_only: "Strategy B — create ConfigMap with minimal config override" +``` + +### Docker Hub Namespace Mismatch (Prevents Unnecessary Builds) + +```yaml +detection: + # Primary: / + primary: "${github_owner}/${github_repo}" + + # Fallback 1: / (when owner ≠ repo) + fallback_repo_repo: "${github_repo}/${github_repo}" + + # Fallback 2: README scan for docker pull/run references + fallback_readme: "scan README.md for image references" +``` diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/modules/pipeline.md b/plugins/labring/sealos-skills/skills/sealos-deploy/modules/pipeline.md new file mode 100644 index 00000000..40415ab1 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/modules/pipeline.md @@ -0,0 +1,1359 @@ +# Deployment Pipeline + +After preflight passes, execute Phase 1–6 in order. + +`SKILL_DIR` refers to the directory containing this skill's SKILL.md. Sibling skills are at `/../`. + +Use `ENV` from preflight to choose between script mode (Node.js available) and fallback mode (AI-native). + +## Artifact Directory + +All pipeline outputs are written under `.sealos/` in `WORK_DIR`: + +``` +/.sealos/ +├── config.json ← user configuration overrides (manual, committed to git) +├── state.json ← deployment state (auto-maintained after Phase 6) +├── analysis.json ← project analysis snapshot (regenerated each deploy) +├── build/ ← created only if Phase 4 actually runs +│ └── build-result.json ← Phase 4 result (`success` or `failed`) +└── template/ + └── index.yaml ← Phase 5 Sealos template +``` + +**File responsibilities:** +- `config.json` — optional user overrides (port, base_image, build_command, etc.). Created manually by user, committed to git. All fields optional. +- `analysis.json` — project analysis snapshot written after Phase 1 (language, framework, score, etc.). Regenerated each deploy. +- `state.json` — deployment state written after Phase 6 success. Contains `last_deploy` and `history`. Enables UPDATE mode on subsequent runs. + +**Note:** When reading dockerfile-skill modules (analyze.md, generate.md, build-fix.md), they reference `docker-build/` as their default output path. In this pipeline, always write to `.sealos/build/` instead. Similarly, template output goes to `.sealos/template/` instead of `template/`. + +JSON artifacts under `.sealos/` are governed by explicit schemas in `/schemas/`: +- `config.schema.json` +- `analysis.schema.json` +- `build-result.schema.json` +- `state.schema.json` + +Validate them with: + +```bash +node "/scripts/validate-artifacts.mjs" --dir "$WORK_DIR" +``` + +Writers should validate on write; readers should validate before trusting resume/update state. + +At the very start of the pipeline (before Phase 1), create the base artifact directory: + +```bash +mkdir -p "$WORK_DIR/.sealos" "$WORK_DIR/.sealos/template" +``` + +Create `"$WORK_DIR/.sealos/build"` lazily when Phase 4 starts. If Phase 2 finds an existing image and skips Phase 4, `build/` should remain absent rather than exist as an empty directory. + +**Read user config (if exists):** +If `.sealos/config.json` exists, read it. User-provided values take priority over auto-detection and AI inference throughout the pipeline. + +```json +{ + "port": 8080, + "node_version": "20", + "start_command": "node dist/main.js", + "build_command": "pnpm build:prod", + "system_deps": ["ffmpeg"], + "base_image": "node:20-slim", + "env_overrides": { "NODE_ENV": "production" }, + "skip_phases": ["assess"] +} +``` +All fields are optional. If a field is present, it overrides the corresponding auto-detected value. + +## Deployment Mode Detection + +After preflight, determine whether this is a **first deploy** or an **update** of an existing deployment. + +### Step 1: Check for previous deployment state + +Read `.sealos/state.json` in `WORK_DIR`. If it exists and contains a `last_deploy` key with `app_name`, proceed to Step 2. + +If no `last_deploy` key or file doesn't exist → proceed to **Step 1.5** (attempt discovery from cluster). + +### Step 1.5: Discover existing deployment from cluster (migration) + +Projects deployed by an older version of the skill may have no `last_deploy` section in state.json (or no state.json at all). If `ENV.kubectl` is true and `~/.sealos/kubeconfig` exists, attempt to discover an existing deployment by project name: + +```bash +# Derive the namespace from the sealos kubeconfig +NAMESPACE=$(KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + config view --minify -o jsonpath='{.contexts[0].context.namespace}' 2>/dev/null) + +# Search for a deployment whose name starts with the repo name +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + get deploy -n "$NAMESPACE" \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.containers[0].image}{"\n"}{end}' 2>/dev/null \ + | grep -i "^$REPO_NAME" +``` + +**If a match is found** (e.g., `evershop-uvbp0n0n zhujingyang/evershop:20260309`): + +1. Query the full details to reconstruct the `deployed` state: +```bash +# Get the ingress host +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + get ingress/ -n "$NAMESPACE" \ + -o jsonpath='{.spec.rules[0].host}' 2>/dev/null +``` + +2. Present to user for confirmation: +``` +Found an existing deployment that appears to match this project: + + App: evershop-uvbp0n0n + Image: zhujingyang/evershop:20260309 + URL: https://evershop-4ha6b4mh.gzg.sealos.run + Namespace: ns-qiqovyrm + + Is this the deployment you want to update? (y/n) +``` + +3. If user confirms → write the reconstructed `last_deploy` section to `.sealos/state.json` (create file if needed), then proceed to Step 2. + +4. If user says no, or no match found → **DEPLOY mode** (skip to Resume Detection below). + +### Step 2: Verify deployment is still running (requires kubectl) + +If `ENV.kubectl` is false: +- Inform user: `"Found previous deployment record for {app_name}, but kubectl is not available. Will create a new instance instead."` +- → **DEPLOY mode** + +If `ENV.kubectl` is true, query the cluster: +```bash +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + get deployment/ -n \ + -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null +``` + +- Command fails (deployment deleted or kubeconfig expired) → **DEPLOY mode** (remove `.sealos/state.json` or clear `last_deploy`) +- Command returns current image → proceed to Step 3 + +### Step 3: Ask user + +Present the detected state and let the user choose: + +``` +Detected existing deployment: + App: + Image: + URL: + + 1. Update this deployment (rebuild & push new image) + 2. Deploy as a new instance + +Default: Update +``` + +- User picks **Update** → **UPDATE mode** (jump to Update Path below) +- User picks **New instance** → **DEPLOY mode** (rename state.json to state.json.bak) + +--- + +## Resume Detection + +**Only applies in DEPLOY mode.** Check for artifacts from a previous incomplete deploy using file existence: + +| Condition | Meaning | Behavior | +|-----------|---------|----------| +| `.sealos/state.json` has `last_deploy` | Already deployed | Enter UPDATE mode (handled above) | +| `.sealos/analysis.json` exists | Phase 1 completed | Ask user: skip assessment? | +| `Dockerfile` exists | Phase 3 completed | Skip Dockerfile generation | +| `.sealos/build/build-result.json` exists and `outcome: "success"` | Phase 4 completed | Ask user: skip rebuild? | +| `.sealos/template/index.yaml` exists | Phase 5 completed | Ask user: skip template generation? | + +If any artifacts exist, report to user: +`"Found artifacts from a previous deploy attempt. [list found artifacts]."` +Ask: `"Resume from where it left off? Or restart from Phase 1?"` + +If restart → remove `.sealos/analysis.json`, `.sealos/build/`, `.sealos/template/index.yaml` and start fresh. + +--- + +## Phase 1: Assess + +`WORK_DIR`, `GITHUB_URL`, `REPO_NAME`, and README context are already resolved in preflight (Step 2). +Use those directly — no need to re-derive. + +### 1.2 Deterministic Scoring + +**If Node.js available:** +```bash +node "/scripts/score-model.mjs" "$WORK_DIR" +``` +Output: `{ "score": N, "verdict": "...", "dimensions": {...}, "signals": {...} }` + +**If Node.js not available (fallback):** +Perform the scoring yourself by reading project files and applying these rules: + +1. Detect language: `package.json` → Node.js, `go.mod` → Go, `requirements.txt` → Python, `pom.xml` → Java, `Cargo.toml` → Rust +2. Detect framework: read dependency files for known frameworks (Next.js, Express, FastAPI, Gin, Spring Boot, etc.) +3. Check HTTP server: does the project listen on a port? +4. Check state: external DB (PostgreSQL/MySQL/MongoDB) vs local state (SQLite)? +5. Check config: `.env.example` exists? +6. Check Docker: `Dockerfile` or `docker-compose.yml` exists? + +Score 6 dimensions (0-2 each, max 12). For detailed criteria, read: +`/../cloud-native-readiness/knowledge/scoring-criteria.md` + +**Decision:** +- `score < 4` → STOP. Tell user: "This project scored {N}/12 ({verdict}). Not suitable for containerized deployment because: {dimension_details for 0-score dimensions}." +- `score >= 4` → CONTINUE. + +### 1.3 AI Quick Assessment + +Use structured signals from Phase 1.2 score-model output directly: +- `signals.primary_language` — primary language (priority-sorted when multiple detected) +- `signals.framework` — detected frameworks +- `signals.package_manager` — detected package manager (npm/yarn/pnpm/bun/pip/go/etc.) +- `signals.port` — detected port (from framework defaults) +- `signals.databases` — detected database types (postgres/mysql/mongodb/redis/sqlite) +- `signals.runtime_version` — runtime version with source (e.g., `{ node: "22", source: "engines" }`) +- `signals.is_monorepo`, `signals.has_docker`, `signals.has_env_example` + +Focus AI effort on what the script cannot detect: env_vars classification, +complexity_tier assessment, and port override from source code (if `port_source` is "unknown"). + +Based on the score result and your own analysis of the project, assess: + +1. Read key files: `README.md`, `package.json`/`go.mod`/`requirements.txt`, `Dockerfile` (if exists) +2. Check: Is this a web service, API, or worker with network interface? +3. Determine: ports, required env vars, database dependencies, special concerns + +If the score is borderline (4-6), also read: +- `/../cloud-native-readiness/knowledge/scoring-criteria.md` — detailed rubrics +- `/../cloud-native-readiness/knowledge/anti-patterns.md` — disqualifying patterns + +**STOP conditions:** +- Desktop/GUI application (Electron without server, Qt, GTK) +- Mobile app without backend +- CLI tool / library / SDK (no network service) +- No identifiable entry point or build system + +Record for later phases: `language`, `framework`, `ports`, `env_vars`, `databases`, `has_dockerfile` + +**Env var classification** (for Phase 5.5 interactive configuration): +When recording `env_vars`, also classify each one: +- `auto` — can be auto-generated (random secrets, internal URLs, DB connections) +- `required` — user must provide (external API keys, admin email, SMTP, OAuth) +- `optional` — has sensible default, user may customize (log level, feature flags) + +Sources for env var detection: +- `.env.example` or `.env.sample` — most reliable source of required env vars +- `docker-compose.yml` `environment:` section +- README sections about configuration/environment +- Source code imports of `process.env.*` or `os.environ[]` + +### Write analysis.json + +After Phase 1 completes, write `.sealos/analysis.json` with the full analysis snapshot: + +```json +{ + "generated_at": "", + "project": { + "github_url": "", + "work_dir": "", + "repo_name": "", + "branch": "" + }, + "score": { "total": "", "verdict": "", "dimensions": {} }, + "language": "", + "all_languages": [""], + "framework": "", + "package_manager": "", + "port": "", + "databases": [""], + "runtime_version": { "": "", "source": "" }, + "env_vars": {}, + "has_dockerfile": false, + "complexity_tier": "", + "image_ref": null +} +``` + +If `.sealos/config.json` exists, apply user overrides: e.g., if `config.json` has `"port": 8080`, use that instead of the auto-detected value. Priority: user config > script detection > AI inference. + +The `image_ref` field is set to `null` initially. It will be filled in Phase 2 (if existing image found) or Phase 4 (after build). + +### Present Analysis Summary + +After writing `.sealos/analysis.json`, present a concise repository analysis summary to the user. +This summary should expose only the key conclusions, not the full artifact contents. + +Recommended format: + +```text +Repository Analysis: + - Type: + - Language: + - Framework: + - Port: + - Database: + - Dockerfile: + - Score: /12 () + - Decision: +``` + +Output rules: +- Keep the summary short and decision-oriented +- Do not dump the full `env_vars` object or dimension-by-dimension internals unless the user asks +- Do not add a default "full details" block after this summary +- If the assessment stops the pipeline, briefly state the top blocker(s) +- If the assessment continues, state the next phase in one short line + +--- + +## Phase 2: Detect Existing Image + +**If Node.js available:** +```bash +# With GitHub URL: +node "/scripts/detect-image.mjs" "$GITHUB_URL" "$WORK_DIR" +# Local project without GitHub URL: +node "/scripts/detect-image.mjs" "$WORK_DIR" +``` +The script auto-detects GitHub URL from `git remote` if only a directory is given. + +Output: `{ "found": true, "image": "...", "tag": "...", ... }` or `{ "found": false }` + +**If Node.js not available (fallback — use curl):** + +1. Parse owner/repo from `GITHUB_URL` (if empty, try `git -C "$WORK_DIR" remote get-url origin`) +2. If still no GitHub URL, skip Docker Hub / GHCR checks and only scan project files for image references +3. Docker Hub check (try `/`, then `/` if different): +```bash +curl -sf "https://hub.docker.com/v2/namespaces//repositories//tags?page_size=10" +# If not found and owner != repo: +curl -sf "https://hub.docker.com/v2/namespaces//repositories//tags?page_size=10" +``` +4. GHCR check: +```bash +TOKEN=$(curl -sf "https://ghcr.io/token?scope=repository:/:pull" | grep -o '"token":"[^"]*"' | cut -d'"' -f4) +curl -sf -H "Authorization: Bearer $TOKEN" "https://ghcr.io/v2///tags/list" +``` +5. **docker-compose.yml scan** — AI reads `docker-compose.yml` / `docker-compose.yaml` (already in Phase 1 context) and extracts `image:` fields. Exclude infrastructure images (postgres, mysql, redis, mongo, etc.). For each candidate, verify with curl against Docker Hub or GHCR. +6. **CI workflow scan** — AI reads `.github/workflows/*.yml` and extracts `docker push` targets, `images:` fields, and `tags:` references. Verify each candidate. +7. Search `README.md` for `ghcr.io/` references, `docker run/pull` commands, and `hub.docker.com/r//` URLs +8. **Docker Hub search API** (catch-all) — if nothing found above: +```bash +curl -sf "https://hub.docker.com/v2/search/repositories/?query=&page_size=5" +# For each result, fetch detail and check if full_description mentions github.com// +curl -sf "https://hub.docker.com/v2/repositories///" +``` +9. For any candidate, verify amd64: `docker manifest inspect :` + +Prefer versioned tags (`v1.2.3`) over `latest`. + +### Phase 2 Post-Verification (AI) + +After Phase 2 produces a result, the AI should cross-validate: + +1. **If `source` is `dockerhub` or `ghcr`** (direct owner/repo match) — high confidence, no extra validation needed. +2. **If `source` is `compose`, `ci-workflow`, `dockerhub-readme`, or `dockerhub-search`** — cross-check with project context: + - Does the README mention this image or its namespace? + - Does `docker-compose.yml` reference it? + - Does the Docker Hub repo description link back to this GitHub project? + - If multiple signals agree → high confidence. If only one signal → note as medium confidence in your assessment. +3. **If `found: false`** — the AI should use its Phase 1 analysis context to attempt one more check: if Phase 1 identified a Docker image name from project docs or code that the script didn't find, try verifying it manually with curl. + +### Update analysis.json + +If an existing image is found, update `.sealos/analysis.json` to set `image_ref` to `{image}:{tag}`. + +**Decision:** +- Found amd64 image → record `IMAGE_REF = {image}:{tag}`, **skip to Phase 5** +- Not found → continue to Phase 3 + +--- + +## Phase 3: Dockerfile + +### 3.1 Check Existing Dockerfile + +If `WORK_DIR/Dockerfile` exists: +1. Read it and assess quality +2. Reasonable (multi-stage or appropriate for language) → use directly, go to Phase 4 +3. Problematic (uses `:latest`, runs as root, missing essential deps) → fix, then Phase 4 + +### 3.2 Generate Dockerfile + +If no Dockerfile exists, generate one. + +**Load the appropriate template from the internal dockerfile-skill:** +``` +/../dockerfile-skill/templates/golang.dockerfile +/../dockerfile-skill/templates/nodejs-express.dockerfile +/../dockerfile-skill/templates/nodejs-nextjs.dockerfile +/../dockerfile-skill/templates/python-fastapi.dockerfile +/../dockerfile-skill/templates/python-django.dockerfile +/../dockerfile-skill/templates/java-springboot.dockerfile +``` + +Read the template matching the detected language/framework, then adapt it: +- Replace placeholder ports with detected ports +- Adjust build commands based on actual package manager (npm/yarn/pnpm/bun) +- Add system dependencies if needed +- Set correct entry point + +**Pre-load Phase 1 analysis for analyze.md:** + +Read `.sealos/analysis.json` before running analyze.md. The following fields are available +as pre-loaded context, so analyze.md can skip its overlapping detection steps: +`language`, `framework`, `package_manager`, `port`, `databases`, `has_dockerfile`, `complexity_tier`. + +**For detailed analysis guidance, read:** +``` +/../dockerfile-skill/modules/analyze.md — 17-step analysis process +/../dockerfile-skill/modules/generate.md — generation rules and best practices +``` + +**Validate generated Dockerfile:** + +After generating the Dockerfile, run validation if Node.js is available: +```bash +node "/../dockerfile-skill/scripts/validate-dockerfile.mjs" "$WORK_DIR/Dockerfile" --port= --json +``` +If validation reports errors, fix the Dockerfile before proceeding to Phase 4. +If Node.js is not available, manually verify the Validation Checklist in generate.md. + +**Key Dockerfile principles:** +- Multi-stage build (builder + runtime) +- Pin base image versions (never `:latest`) +- Run as non-root user (USER 1001) +- Proper `.dockerignore` + +Also generate `.dockerignore`: +``` +.git +node_modules +__pycache__ +.env +.env.local +*.md +.vscode +.idea +.sealos +``` + +--- + +## Phase 4: Build & Push + +### 4.0 Choose Image Destination + +Registry selection is deferred to this phase because it's only needed when building. +If Phase 2 found an existing image, this phase is skipped entirely. + +Before any login step, tell the user: + +```text +This app will be built locally with Docker. +Choose where to push the image: + + 1. GHCR (recommended) — agent can run `gh auth login` and finish browser auth with you + 2. Docker Hub — public images only; use your existing `docker login` session, or run `docker login` in another terminal +``` + +Default to **GHCR** when the user says "either is fine". + +Important: +- This choice is about the image registry only. Local builds still require Docker either way. +- If the user chooses GHCR, use `gh auth login` as the preferred interactive auth path. +- If the user chooses Docker Hub, treat that path as public-image only. +- If the user chooses Docker Hub and there is no active Docker Hub session, stop and ask the user to run `docker login` in another terminal before continuing. + +**If the user chooses GHCR:** +```bash +gh auth status 2>/dev/null +``` +If authenticated: +```bash +GH_USER=$(gh api user -q .login) +gh auth token | docker login ghcr.io -u "$GH_USER" --password-stdin +REGISTRY=ghcr +``` +Important: +- Before the first GHCR push, ensure the local `gh` session has `write:packages`. +- For GHCR, `write:packages` is sufficient for both pushing and later creating the app-scoped image pull Secret. GitHub CLI may not show a separate `read:packages` entry even though pull access works. +- If the current session is missing GHCR package access, refresh with: + `node "/scripts/gh-refresh-scopes.mjs" write:packages` +- When `build-push.mjs` or `ensure-image-pull-secret.mjs` runs inside a TTY, it will now ask once whether it should refresh missing GHCR scopes and, on `y`, run `gh auth refresh` in the same PTY before continuing. +- If `gh auth refresh` exits successfully but the scopes are still missing, the script will immediately fall back to a full `gh auth login --web --scopes ...` in the same PTY and only continue after re-checking the scopes. +- A successful GHCR push does **not** guarantee Sealos can pull the image. +- For private GHCR packages, keep the deployment path GHCR-first and create an image pull Secret from the local `gh` CLI session before applying or updating workloads. +- Do **not** surface raw registry host/username/password/email as user-facing template inputs when local `gh auth status` is already available. + +If `build-push.mjs` or `ensure-image-pull-secret.mjs` returns: +```json +{ + "action": "gh_scope_refresh_required", + "tty_required": true, + "suggested_command": "node /scripts/gh-refresh-scopes.mjs write:packages" +} +``` +then the agent should: +1. Ask the user once: `Missing GitHub Packages permission for GHCR. Refresh now? (y/n)` +2. If the current script is already running in a PTY, answer `y` there and let it continue in-place +3. Otherwise run the `suggested_command` in the **current PTY/TTY session** +4. If `gh` prompts `Press Enter to open github.com in your browser...`, send `Enter` in the same PTY +5. After the refresh command exits successfully, retry the exact failed command automatically + +Do not tell the user to open a separate terminal when the current agent session can run a PTY command. + +If `gh` is installed but not authenticated, explicitly tell the user that GHCR push requires GitHub CLI login, then trigger: +```bash +gh auth login +``` +After successful login, retry GHCR authentication and continue. + +**If the user chooses Docker Hub:** +```bash +docker info 2>/dev/null | grep "Username:" +``` +If a Docker Hub session exists, use it: +```bash +DOCKER_HUB_USER= +REGISTRY=dockerhub +``` + +Treat this path as **public image only**. +Do not add Docker Hub private-image credential prompts or Docker Hub pull-secret automation in `sealos-deploy`. + +If no Docker Hub session exists, tell the user: +``` +Docker Hub push requires a local Docker Hub login session. +Please run `docker login` in another terminal, then continue this deploy. +``` + +### 4.1 Build & Push + +Tag format: `/:YYYYMMDD-HHMMSS` (e.g., `ghcr.io/zhujingyang/kite:20260304-143022`). The timestamp ensures same-day rebuilds never collide. + +Before invoking the build helper, create the build artifact directory: + +```bash +mkdir -p "$WORK_DIR/.sealos/build" +``` + +**If Node.js available:** +```bash +node "/scripts/build-push.mjs" "$WORK_DIR" "" --registry ghcr +node "/scripts/build-push.mjs" "$WORK_DIR" "" --registry dockerhub --user "" +``` +Run the command that matches the user's chosen destination: +- GHCR: `node "/scripts/build-push.mjs" "$WORK_DIR" "" --registry ghcr` +- Docker Hub: `node "/scripts/build-push.mjs" "$WORK_DIR" "" --registry dockerhub` + +Output: `{ "success": true, "image": "...", "registry": "ghcr" }` or `{ "success": false, "error": "..." }` + +For GHCR success, record whether the image is anonymously pullable. If Phase 4 built a GHCR image and it is still private, continue with the GHCR image and let Phase 6 create/update the pull Secret automatically from `gh auth token`. +If Phase 2 reused an existing public image, do **not** trigger the GHCR pull-secret flow. + +**If Node.js not available (fallback — run docker directly):** +```bash +TAG=$(date +%Y%m%d-%H%M%S) +``` + +If the user chose GHCR: +```bash +GH_USER=$(gh api user -q .login) +gh auth token | docker login ghcr.io -u "$GH_USER" --password-stdin +IMAGE="ghcr.io/$GH_USER/:$TAG" +docker buildx build --platform linux/amd64 -t "$IMAGE" --push -f Dockerfile "$WORK_DIR" +``` + +If the user chose Docker Hub: +```bash +DOCKER_HUB_USER=$(docker info 2>/dev/null | sed -n 's/^ Username: //p') +IMAGE="$DOCKER_HUB_USER/:$TAG" +docker buildx build --platform linux/amd64 -t "$IMAGE" --push -f Dockerfile "$WORK_DIR" +``` + +If `$IMAGE` is a GHCR image, immediately verify it is anonymously pullable before proceeding: + +```bash +TOKEN=$(curl -fsSL "https://ghcr.io/token?scope=repository:$GH_USER/:pull" | sed -n 's/.*"token":"\\([^"]*\\)".*/\\1/p') +curl -fsSLI \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.v2+json" \ + "https://ghcr.io/v2/$GH_USER//manifests/$TAG" +``` + +If that check returns 401/403 or the package visibility is still private, continue with the build but mark that Phase 6 must create/update the namespace image pull Secret before rollout. +If the run is using an existing public image instead of a new local build, skip this secret-creation path. + +### 4.2 Error Handling + +If build fails: +1. Read the error output +2. Load error patterns from internal skill: + ``` + /../dockerfile-skill/knowledge/error-patterns.md + ``` +3. Match the error → apply fix to Dockerfile → retry +4. Also consult if needed: + ``` + /../dockerfile-skill/knowledge/system-deps.md + /../dockerfile-skill/knowledge/best-practices.md + ``` +5. Max 3 retry attempts +6. If still failing → inform user with the specific error and suggest manual review + +### 4.3 Record Result + +Always write `.sealos/build/build-result.json` when Phase 4 runs: + +- Success: `outcome: "success"` plus pushed image metadata +- Failure: `outcome: "failed"` plus the captured error message + +This avoids leaving an empty `build/` directory after a failed build and makes resume/debug behavior inspectable. + +On success, record `IMAGE_REF` from the build output. The build result file is at `.sealos/build/build-result.json`. + +### Update analysis.json + +On successful build, update `.sealos/analysis.json` to set `image_ref` to the built image reference. + +--- + +## Phase 5: Generate Sealos Template + +### 5.1 Load Sealos Rules + +Read the internal skill's specifications: +``` +/../docker-to-sealos/SKILL.md — 7-step workflow + MUST rules +/../docker-to-sealos/references/sealos-specs.md — Sealos ordering, labels, conventions +/../docker-to-sealos/references/conversion-mappings.md — field-level Docker→Sealos mappings +``` + +If the project uses databases, also read: +``` +/../docker-to-sealos/references/database-templates.md +``` + +If the project mentions Frappe, ERPNext, HRMS, or `bench`, also read: +``` +/../docker-to-sealos/references/frappe-bench.md +``` + +### 5.2 Generate Template + +Read `.sealos/analysis.json` and use `image_ref`, `port`, `databases`, and `env_vars` as inputs. + +Generate the template at `.sealos/template/index.yaml` (overrides the default `template/` path from docker-to-sealos skill). + +**Public URL detection:** +After generating the base template, check if the app needs its public URL configured: + +1. Search source code for common URL config patterns: + - Env vars: `BASE_URL`, `SITE_URL`, `APP_URL`, `NEXTAUTH_URL`, `PUBLIC_URL`, `EXTERNAL_URL` + - Config files: `getConfig(.*[Uu]rl`, `homeUrl`, `baseUrl`, `siteUrl` in config patterns + - Docker Compose env vars referencing `localhost` or placeholder URLs + +2. If public URL is needed via env var: + - Add the appropriate env var to the Deployment with value `https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}` + +3. If public URL is needed via config file (e.g., node-config): + - Create a ConfigMap with the minimal config file + - Add volumeMount and volume to the Deployment + - Follow ConfigMap MUST rules (labels, naming, ordering before Deployment) + +**Critical MUST rules (always apply):** +- `metadata.name`: hardcoded lowercase, no variables +- Image tag: exact version, **never `:latest`** +- PVC requests: `<= 1Gi` +- Container defaults: `cpu: 200m/20m`, `memory: 256Mi/25Mi` +- Init containers must define explicit resources; do not rely on namespace defaults. For expensive init work such as framework install, database migration, asset compilation, or `bench new-site`, allocate enough memory for the task. +- `imagePullPolicy: IfNotPresent` +- `revisionHistoryLimit: 1` +- `automountServiceAccountToken: false` +- `template.spec.imagePullSecrets: [{ name: ${{ defaults.app_name }} }]` for managed workloads +- **App CRD** (last resource): only `spec.data.url`, `spec.displayType`, `spec.icon`, `spec.name`, `spec.type` — no other fields (no `menuData`, `nameColor`, `template`, etc.) +- **App CRD fixed enums**: `spec.displayType` must be `normal`; `spec.type` must be `link` + +### 5.3 Validate + +Run validation if Python is available: +```bash +python "/../docker-to-sealos/scripts/quality_gate.py" 2>/dev/null +``` + +If Python is not available, validate manually by checking the MUST rules above against the generated YAML. + +Template is written to `.sealos/template/index.yaml`. No separate checkpoint file — the template file's existence is sufficient for resume detection. + +--- + +## Phase 5.5: Interactive Configuration + +After generating the template, guide the user through application configuration before deployment. +This is a **critical** step — most applications need user-specific configuration to function properly. + +### 5.5.1 Extract Configuration from Template + +Parse the generated template YAML and categorize all environment variables and inputs: + +**Category A — Auto-managed (no user action needed):** +- `defaults.*` values: `app_name`, `app_host`, random passwords/keys (`${{ random(N) }}`) +- Database connections via `secretKeyRef`: host, port, username, password from Kubeblocks secrets +- Object storage credentials via `secretKeyRef` +- Composed URLs that reference auto-managed vars (e.g., `DATABASE_URL` built from `$(DB_HOST):$(DB_PORT)`) +- Internal service FQDNs (`*.${{ SEALOS_NAMESPACE }}.svc.cluster.local`) + +**Category B — User-required inputs:** +- Template `inputs` with `required: true` and no sensible default +- Env vars with empty or placeholder values that the app cannot function without +- Common examples: admin email, external API keys (OpenAI, SMTP credentials, OAuth client ID/secret) + +**Category C — Optional with defaults:** +- Template `inputs` with `required: false` and reasonable defaults +- Env vars user might want to customize but app works without changes +- Common examples: log level, feature toggles, upload size limits, signup enabled/disabled + +**Category D — Fixed values (informational):** +- Hardcoded env vars like `NODE_ENV=production` +- Port numbers, internal paths + +### 5.5.2 Present Configuration Summary + +Display a structured summary to the user. Example: + +``` +Configuration for : + + Auto-configured (no action needed): + - APP_NAME: unique generated name + - DB credentials: from PostgreSQL service (auto-provisioned) + - SECRET_KEY: auto-generated 32-char random string + - REDIS_URL: auto-composed from service credentials + + Requires your input: + 1. ADMIN_EMAIL — Administrator email address (required) + 2. OPENAI_API_KEY — OpenAI API key for AI features (required) + 3. SMTP_HOST — SMTP server for sending emails (required if email needed) + + Optional (defaults shown, customize if needed): + - LOG_LEVEL: "info" + - MAX_UPLOAD_SIZE: "10M" + - ENABLE_SIGNUP: "true" +``` + +### 5.5.3 Collect User Input + +**For required inputs:** +1. Ask the user for each value +2. If user doesn't have a value, explain what it's used for and how to obtain it + - Example: "OPENAI_API_KEY is needed for AI features. Get one at https://platform.openai.com/api-keys" +3. If user wants to skip a feature-gating input (e.g., SMTP), explain which features will be unavailable and set an empty value + +**For optional inputs:** +1. Show the default values +2. Ask: "Do you want to change any of these? (press Enter to keep defaults)" +3. Only update values the user explicitly wants to change + +**For unfamiliar env vars:** +If the AI is unsure what a variable does, read the project README, `.env.example`, or source code to explain it to the user before asking for a value. + +### 5.5.4 Apply Configuration to Template + +Update the template's `inputs` section with user-provided values: + +```yaml +# Before (generated) +inputs: + ADMIN_EMAIL: + description: 'Administrator email address' + type: string + default: '' + required: true + +# After (user configured) +inputs: + ADMIN_EMAIL: + description: 'Administrator email address' + type: string + default: 'admin@example.com' + required: true +``` + +Write the updated template back to `.sealos/template/index.yaml`. + +Record all user choices as `CONFIG` for use in Phase 6: +``` +CONFIG.args = { ADMIN_EMAIL: "admin@example.com", OPENAI_API_KEY: "sk-..." } +``` +These `args` will be passed to the Template API's `args` field (Phase 6.2), which overrides or supplies `spec.inputs` in the template. + +### 5.5.5 Deployment Confirmation + +Before proceeding to Phase 6, present a final summary and ask for confirmation: + +``` +Ready to deploy to Sealos Cloud: + + Image: zhujingyang/app:20260309 + Region: https://usw-1.sealos.io + Database: PostgreSQL 16 (auto-provisioned) + Config: 3 required inputs configured, 2 optional defaults kept + + Proceed with deployment? (y/n) +``` + +Wait for user confirmation before continuing to Phase 6. + +Configuration is applied directly to `.sealos/template/index.yaml`. No separate checkpoint — the template contains the final configured state. + +--- + +## Phase 6: Deploy to Sealos Cloud + +### 6.1 Construct Deploy URL + +The template deploy API uses a fixed `template.` subdomain prefix on the region domain: + +``` +Region example: https://usw-1.sealos.io +Deploy URL example: https://template.usw-1.sealos.io/api/v2alpha/templates/raw +``` + +Do not send requests to the literal placeholder form `https://template./...`. +Always derive `REGION_DOMAIN` first, then build `DEPLOY_URL` from the real value. + +Extract the region from `~/.sealos/auth.json` (saved during preflight auth): +```bash +REGION=$(jq -r '.region' ~/.sealos/auth.json) +REGION_DOMAIN=$(printf '%s' "$REGION" | sed -E 's#^https?://##; s#/$##') +DEPLOY_URL="https://template.${REGION_DOMAIN}/api/v2alpha/templates/raw" +``` + +### 6.2 Deploy Template + +Read kubeconfig, **encode it with `encodeURIComponent`**, and send as `Authorization` header. + +Request body fields: +- `yaml` (required) — the full template YAML string +- `args` (optional) — template variable key-value pairs that override or supply `spec.inputs` fields. Values from Phase 5.5 `CONFIG.args`. +- `dryRun` (optional, boolean) — if true, validates resources against K8s API without creating anything. Returns 200 with preview. + +**With Node.js (preferred):** +```bash +node "/scripts/deploy-template.mjs" ".sealos/template/index.yaml" --dry-run +node "/scripts/deploy-template.mjs" ".sealos/template/index.yaml" --args-json '{"ADMIN_EMAIL":"user@example.com"}' +``` + +This script is the preferred execution path because it: +- reads `~/.sealos/auth.json` directly instead of fragile shell parsing +- derives `REGION_DOMAIN` from the real `region` value +- always posts to the concrete `DEPLOY_URL` +- emits structured JSON on success or failure + +**Without Node.js (curl fallback):** +```bash +# encodeURIComponent via Python (almost always available) +KUBECONFIG_ENCODED=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.stdin.read(), safe=''))" < ~/.sealos/kubeconfig) + +# Build JSON body with args — use jq if available +TEMPLATE_YAML=$(cat .sealos/template/index.yaml) +jq -n --arg yaml "$TEMPLATE_YAML" \ + --argjson args '{"ADMIN_EMAIL":"user@example.com"}' \ + '{yaml: $yaml, args: $args}' | \ + curl -sf -X POST "$DEPLOY_URL" \ + -H "Authorization: $KUBECONFIG_ENCODED" \ + -H "Content-Type: application/json" \ + -d @- +``` + +**Without jq:** +The AI should read the template YAML (already in context), construct the JSON body directly, write it to a temp file, and curl it: +```bash +# AI writes properly escaped JSON to temp file including args from Phase 5.5 +cat > /tmp/sealos-deploy-body.json << 'DEPLOY_EOF' +{"yaml": "", "args": {"ADMIN_EMAIL": "user@example.com"}} +DEPLOY_EOF + +curl -sf -X POST "$DEPLOY_URL" \ + -H "Authorization: $KUBECONFIG_ENCODED" \ + -H "Content-Type: application/json" \ + -d @/tmp/sealos-deploy-body.json + +rm -f /tmp/sealos-deploy-body.json +``` + +### 6.3 Handle Response + +All error responses use a unified format: +```json +{ "error": { "type": "...", "code": "...", "message": "...", "details": ... } } +``` + +| Status | Meaning | Action | +|--------|---------|--------| +| 201 | Deployed successfully | Extract instance name and resources from response | +| 200 | Dry-run preview (`dryRun: true`) | Show resource preview and quota | +| 400 | Validation error — `INVALID_PARAMETER` (missing yaml/name) or `INVALID_VALUE` (bad YAML, missing required args) | Read `error.message`, fix template or provide missing `args`, retry | +| 401 | `AUTHENTICATION_REQUIRED` — missing or invalid kubeconfig | Re-run auth: `node sealos-auth.mjs login`, or switch workspace: `node sealos-auth.mjs switch ` | +| 403 | `FORBIDDEN` — insufficient permissions | Inform user, check kubeconfig namespace permissions | +| 409 | `ALREADY_EXISTS` — instance already exists | Inform user, suggest different app name | +| 422 | `RESOURCE_ERROR` — K8s rejected resource spec | Read `error.details` for K8s rejection reason, fix template | +| 503 | `SERVICE_UNAVAILABLE` — K8s cluster unreachable | **Fall back to kubectl (6.4)** | + +On 201 success, the response contains: +```json +{ + "name": "myapp-abcdefgh", + "uid": "...", + "resourceType": "instance", + "displayName": "...", + "createdAt": "...", + "args": { ... }, + "resources": [ + { "name": "myapp-abcdefgh", "uid": "...", "resourceType": "deployment", "quota": { "cpu": 0.1, "memory": 0.25, "storage": 0, "replicas": 1 } } + ] +} +``` +Extract the instance name and present to user. + +### 6.3.1 Post-Deploy Readiness Verification + +After a 201 response, do not assume the app is usable. Verify Kubernetes readiness: + +```bash +NAMESPACE=$(KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + config view --minify -o jsonpath='{.contexts[0].context.namespace}') + +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + get pod,svc,endpoints,ingress -n "$NAMESPACE" -l app= +``` + +For the public app Service, endpoints must be non-empty before the Ingress can serve traffic. If the URL returns `no healthy upstream` or HTTP 503: + +1. Check `endpoints/`; empty endpoints means the backend Pod is not Ready. +2. Check Pod init container status and previous logs: + ```bash + KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + logs pod/ -n "$NAMESPACE" -c --previous --tail=200 + ``` +3. Look for common signatures: + - `OOMKilled` or exit `137`: increase init container memory and recreate the Pod. + - `Permission denied` on mounted paths: add `fsGroup` or a one-shot permission repair for existing PVCs. + - App-specific migration/bootstrap errors: repair the failed bootstrap state, then rerun the init path. +4. Only report the app as usable after the endpoint exists and an HTTP request to the public URL returns a non-5xx response. + +### 6.4 Fallback: kubectl apply (when Template API is unavailable) + +If the Template API returns 503/500 or is unreachable, deploy directly via kubectl using the local kubeconfig. + +**Step 1 — Gather cluster context:** +```bash +# User namespace +NAMESPACE=$(KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify config view --minify -o jsonpath='{.contexts[0].context.namespace}') + +# Cluster domain (from region URL) +CLOUD_DOMAIN=$(jq -r '.region' ~/.sealos/auth.json | sed -E 's#^https?://##; s#/$##') + +# TLS secret name (from existing ingress, or default) +CERT_SECRET=$(KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify get ingress -n "$NAMESPACE" -o jsonpath='{.items[0].spec.tls[0].secretName}' 2>/dev/null || echo "wildcard-cert") +``` + +**Step 2 — Render template variables:** + +The template YAML from Phase 5 contains `${{ }}` variables. The AI must replace them with actual values: + +| Variable | Value | +|----------|-------| +| `${{ defaults.app_name }}` | Generate: `-` (e.g., `edict-xn22k4ie`) | +| `${{ defaults.app_host }}` | Generate: `-` (e.g., `edict-2v4jryz1`) | +| `${{ defaults. }}` | Other defaults: generate per their `value` pattern | +| `${{ inputs. }}` | User-provided values from Phase 5.5 `CONFIG.args` | +| `${{ random(N) }}` | Random alphanumeric string of length N | +| `${{ SEALOS_CLOUD_DOMAIN }}` | `CLOUD_DOMAIN` from Step 1 | +| `${{ SEALOS_CERT_SECRET_NAME }}` | `CERT_SECRET` from Step 1 | +| `${{ SEALOS_NAMESPACE }}` | `NAMESPACE` from Step 1 | + +**Important:** `${{ inputs.xxx }}` values come from the user in Phase 5.5. If any required input was not provided, the AI must ask the user now before proceeding. + +The AI reads the template YAML, performs all variable substitutions, and produces rendered K8s resource documents. + +**Step 3 — Split and apply:** + +The rendered YAML is a multi-document file (separated by `---`). Split it into individual resources: + +1. **Skip** the first document (`kind: Template`) — this is the Sealos template metadata, not a K8s resource +2. **Apply** the remaining documents (Deployment, Service, Ingress, App, etc.) via kubectl: + +```bash +# AI writes the rendered resources (without the Template CR) to a temp file +cat > /tmp/sealos-deploy-rendered.yaml << 'EOF' + +EOF + +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify apply -f /tmp/sealos-deploy-rendered.yaml -n "$NAMESPACE" +rm -f /tmp/sealos-deploy-rendered.yaml +``` + +**Step 4 — Handle apply errors:** + +| Error | Fix | +|-------|-----| +| `unknown field "spec.xxx"` in App CR | Remove the unknown field and retry | +| PodSecurity warnings | Warnings are non-blocking — deployment still proceeds | +| `Forbidden` | Kubeconfig may be expired — re-run auth | +| `already exists` | Resource exists from a previous deploy — use `kubectl apply` (idempotent) | + +**Step 5 — Verify deployment:** +```bash +# Wait for pod to be ready (max 120s) +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + wait --for=condition=available deployment/ -n "$NAMESPACE" --timeout=120s + +# Get pod status +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + get pods -l app= -n "$NAMESPACE" +``` + +App URL: `https://.` + +### Write state.json + +**This is critical for enabling future updates.** After a successful deploy, write `.sealos/state.json`: + +```json +{ + "version": "1.0", + "last_deploy": { + "app_name": "", + "app_host": "", + "namespace": "", + "region": "", + "image": "", + "docker_hub_user": "", + "repo_name": "", + "url": "", + "deployed_at": "", + "last_updated_at": "" + }, + "history": [ + { + "at": "", + "action": "deploy", + "image": "", + "method": "", + "status": "success", + "note": "Initial deployment" + } + ] +} +``` + +The `last_deploy` section is what **Deployment Mode Detection** reads on subsequent runs to decide between DEPLOY and UPDATE mode. Without it, every `/sealos-deploy` creates a new instance. + +The `history` array is append-only — every subsequent update (via Update Path) adds an entry. See the **Update History** section at the end of this file for the full schema and rules. + +Sources for each field: +- `app_name`: from Template API response `name` or the rendered `defaults.app_name` (kubectl apply) +- `app_host`: from the rendered `defaults.app_host` value, or parsed from the Ingress host +- `namespace`: from kubeconfig context +- `region`: from `~/.sealos/auth.json` `region` field (strip `https://`) +- `image`: from `analysis.json` `image_ref` +- `docker_hub_user`: from Phase 4 `DOCKER_HUB_USER` (null if Phase 2 found existing image) +- `repo_name`: from `analysis.json` `project.repo_name` +- `url`: constructed from `app_host` and `region` + +--- + +## Cleanup + +If `WORK_DIR` was created via `mktemp` (remote GitHub URL clone), remove it: +```bash +rm -rf "$WORK_DIR" +``` + +Do NOT clean up if `WORK_DIR` is the user's local project directory. + +--- + +## Output + +On success, present to user: + +``` +✓ Assessed: {language} + {framework}, score {N}/12 — {verdict} +✓ Image: {IMAGE_REF} ({source: existing/built}) +✓ Template: .sealos/template/index.yaml +✓ Configured: {N} inputs set ({M} required, {K} optional) +✓ Deployed to Sealos Cloud ({region}) + +App URL: https:// + +To update this deployment later, run: /sealos-deploy +``` + +If any `inputs` were configured, also show: +``` +Configuration applied: + ADMIN_EMAIL: admin@example.com + OPENAI_API_KEY: sk-***...*** (masked) +``` +Mask sensitive values (API keys, passwords) — show only first 3 and last 3 characters. + +--- +--- + +# Update Path + +**This section is only executed in UPDATE mode** (entered via Deployment Mode Detection above). + +The update path skips Assess, Detect Image, Dockerfile, and Template generation — it reuses the existing deployment and only pushes a new image. + +All kubectl commands use the Sealos kubeconfig: +``` +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify +``` + +**Reminder:** `kubectl delete` requires user confirmation — see SKILL.md "kubectl Safety Rules". + +## Context from Mode Detection + +These values are already known from `.sealos/state.json` `last_deploy` section: + +``` +APP_NAME = last_deploy.app_name (e.g., "evershop-uvbp0n0n") +NAMESPACE = last_deploy.namespace (e.g., "ns-qiqovyrm") +REGION = last_deploy.region (e.g., "gzg.sealos.run") +CURRENT_IMAGE = last_deploy.image (e.g., "zhujingyang/evershop:20260309") +DOCKER_HUB_USER = last_deploy.docker_hub_user +REPO_NAME = last_deploy.repo_name +APP_URL = last_deploy.url +``` + +--- + +## Phase U1: Build & Push + +Ask the user what changed: + +``` +What would you like to update? + + 1. Code changed — rebuild and push new image (default) + 2. Just restart the current deployment (no rebuild) +``` + +### Option 1: Rebuild + +Reuse the **exact same build logic as Phase 4** — same Dockerfile, same explicit registry choice, same build-push.mjs or fallback. +Default to the registry used by `CURRENT_IMAGE`, but let the user switch if they want. + +```bash +# With Node.js: +node "/scripts/build-push.mjs" "$WORK_DIR" "$REPO_NAME" --registry ghcr +node "/scripts/build-push.mjs" "$WORK_DIR" "$REPO_NAME" --registry dockerhub + +# Without Node.js: +TAG=$(date +%Y%m%d-%H%M%S) +NEW_IMAGE="/$REPO_NAME:$TAG" +docker buildx build --platform linux/amd64 -t "$NEW_IMAGE" --push -f Dockerfile "$WORK_DIR" +``` + +Record `NEW_IMAGE` from the output. + +If build fails → same error handling as Phase 4.2 (read error-patterns.md, fix Dockerfile, retry up to 3 times). + +### Option 2: Restart only + +No build needed. Use the current image: +``` +NEW_IMAGE = CURRENT_IMAGE +``` + +Will trigger a rollout restart in Phase U2. + +--- + +## Phase U2: Apply Update + +### Image update (Option 1 — new image built): + +If `NEW_IMAGE` starts with `ghcr.io/`, create or refresh the app-scoped pull Secret and make sure the existing Deployment references it before swapping images: + +```bash +node "/scripts/ensure-image-pull-secret.mjs" "$NAMESPACE" "$APP_NAME" "$NEW_IMAGE" "$APP_NAME" +``` + +```bash +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + set image deployment/$APP_NAME \ + $APP_NAME=$NEW_IMAGE \ + -n $NAMESPACE +``` + +### Restart only (Option 2 — no new image): + +```bash +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + rollout restart deployment/$APP_NAME \ + -n $NAMESPACE +``` + +--- + +## Phase U3: Verify Rollout + +### Wait for new pods to be ready: + +```bash +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + rollout status deployment/$APP_NAME \ + -n $NAMESPACE --timeout=120s +``` + +### On success: + +Update `.sealos/state.json`: +- Set `last_deploy.image` to `NEW_IMAGE` +- Set `last_deploy.last_updated_at` to current ISO timestamp +- Append an entry to `history` (see Update History below) + +Present to user: +``` +✓ Updated: +✓ Image: +✓ Rollout: complete + +App URL: + +To update again later, run: /sealos-deploy +``` + +### On failure: + +Auto-rollback: +```bash +KUBECONFIG=~/.sealos/kubeconfig kubectl --insecure-skip-tls-verify \ + rollout undo deployment/$APP_NAME \ + -n $NAMESPACE +``` + +Append a **failed** entry to `history` in `.sealos/state.json` (see Update History below). + +Report to user: +``` +✗ Rollout failed — automatically rolled back to previous version. + +Debug: + kubectl logs deployment/ -n --tail=50 +``` + +Do NOT update `last_deploy.image` on failure — it stays at the old value. + +--- + +## Update History + +Every update (successful or failed) appends an entry to `history` in `.sealos/state.json`. This provides a traceable log of all changes to the deployment. + +```json +{ + "version": "1.0", + "last_deploy": { + "app_name": "morphic-dc21ad72", + "image": "zhujingyang/morphic:20260310-143022" + }, + "history": [ + { + "at": "2026-03-09T18:37:30Z", + "action": "deploy", + "image": "ghcr.io/miurla/morphic:668daf0e", + "method": "kubectl-apply", + "status": "success", + "note": "Initial deployment" + }, + { + "at": "2026-03-09T20:15:00Z", + "action": "set-env", + "changes": ["OPENAI_API_KEY=sk-***", "OPENAI_BASE_URL=https://..."], + "method": "kubectl-set-env", + "status": "success", + "note": "Fix: default openai provider not enabled" + }, + { + "at": "2026-03-10T14:30:22Z", + "action": "set-image", + "previous_image": "ghcr.io/miurla/morphic:668daf0e", + "image": "zhujingyang/morphic:20260310-143022", + "method": "kubectl-set-image", + "status": "success" + }, + { + "at": "2026-03-11T09:00:00Z", + "action": "set-image", + "previous_image": "zhujingyang/morphic:20260310-143022", + "image": "zhujingyang/morphic:20260311-090000", + "method": "kubectl-set-image", + "status": "failed", + "note": "CrashLoopBackOff — rolled back" + } + ] +} +``` + +### History entry fields + +| Field | Required | Description | +|-------|----------|-------------| +| `at` | yes | ISO 8601 timestamp of the operation | +| `action` | yes | What changed: `deploy`, `set-image`, `set-env`, `patch`, `restart` | +| `status` | yes | `success` or `failed` | +| `method` | yes | kubectl command used: `kubectl-apply`, `kubectl-set-image`, `kubectl-set-env`, `kubectl-patch`, `kubectl-rollout-restart` | +| `image` | if image changed | New image reference | +| `previous_image` | if image changed | Image before the update | +| `changes` | if env/config changed | Array of changes (mask sensitive values: `sk-***`) | +| `note` | no | Free-text reason or context for the change | + +### Rules + +- **Always append, never rewrite** — history is append-only. Never delete or modify previous entries. +- **Mask secrets** — API keys, passwords, tokens: show only first 3 chars + `***` (e.g., `sk-***`). +- **Initial deploy counts** — the first entry should be `action: "deploy"` written by Phase 6 checkpoint. +- **Failed updates count** — record failures so the user can see what was attempted and why it didn't work. +- **Keep it bounded** — if history exceeds 50 entries, trim the oldest entries (keep the first `deploy` entry and the most recent 49). +### 6.1.5 Ensure Image Pull Secret (locally built private GHCR path only) + +Before calling the Template API or `kubectl apply`, check whether this run actually passed through Phase 4 local build and push. +This step is only for cases where: +- Phase 4 built a new GHCR image locally with Docker +- That GHCR image is not anonymously pullable + +Do **not** run this step when: +- Phase 2 reused an existing public image +- The selected registry was Docker Hub public image flow + +The template itself should reference the app-scoped pull Secret name via: + +```yaml +imagePullSecrets: + - name: ${{ defaults.app_name }} +``` + +If the run meets the locally built private-GHCR conditions above, create or update the app-scoped pull Secret in the target namespace using the local `gh` CLI session: + +```bash +node "/scripts/ensure-image-pull-secret.mjs" "$NAMESPACE" "$APP_NAME" "$IMAGE_REF" +``` + +Behavior: +- Uses `gh api user -q .login` and `gh auth token` +- Creates/updates a `docker-registry` Secret named exactly like the app (`$APP_NAME`) +- When a deployment name is provided, also patches `spec.template.spec.imagePullSecrets` to include that app-scoped Secret +- Keeps registry credentials out of the generated template inputs +- Do not call it for existing public images + +This step should run for both fresh deploys and in-place updates before rollout, but only on the locally built private-GHCR path. diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/modules/preflight.md b/plugins/labring/sealos-skills/skills/sealos-deploy/modules/preflight.md new file mode 100644 index 00000000..9b28972c --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/modules/preflight.md @@ -0,0 +1,538 @@ +# Phase 0: Preflight + +Detect the user's environment, record what's available, guide them to fix what's missing. + +**Hard rule:** Every run must start with a preflight capability scan before touching the project. +That means: +- Detect tool availability first +- Detect auth/workspace state first +- Record which later phases are currently blocked + +Preflight is responsible for early detection, but only some failures are immediate stop conditions. +Do not treat Docker, `gh`, or `buildx` as universal entry requirements — they become mandatory only if the run actually needs local image build/push. + +## Tool Install Policy + +When `docker`, `gh`, or `kubectl` is missing, do not just print commands and stop. +Ask directly: + +```text +Missing . Install it now? (y/n) +``` + +If the user answers `y`, install the tool for the current platform, then re-run the corresponding check. +If the install command needs elevated privileges, package-manager setup, or manual UI interaction, explain that before running it. + +## Step 1: Environment Detection + +Detect the local toolchain on every run. These checks are fast, and re-running them avoids stale results after the user installs a missing dependency such as `gh` or `kubectl`. + +### 1.1 Detect Installed Tools + +Run all checks: + +```bash +# Commonly needed +docker --version 2>/dev/null +git --version 2>/dev/null + +# Optional (enables script acceleration) +node --version 2>/dev/null +python3 --version 2>/dev/null + +# Optional (enables GHCR push — preferred over Docker Hub) +gh --version 2>/dev/null + +# Conditional (required for update-mode rollout operations) +# Check PATH first, then fallback to ~/.agents/bin/ +kubectl version --client 2>/dev/null || ~/.agents/bin/kubectl version --client 2>/dev/null + +# Always available (system built-in) +curl --version 2>/dev/null | head -1 +which jq 2>/dev/null +``` + +Version strings are present when installed, `null` when missing. + +Record as `ENV`: +``` +ENV.docker = true/false +ENV.git = true/false +ENV.node = true/false +ENV.python = true/false +ENV.kubectl = true/false (required for update-mode rollout operations) +ENV.gh = true/false (enables zero-interaction GHCR push) +ENV.curl = true/false +ENV.jq = true/false +``` + +### 1.2 Docker Daemon Check + +Tool detection and Docker daemon status are different checks. Always verify the daemon separately: + +```bash +docker info 2>/dev/null +``` + +- Not installed → guide by platform: + - Ask: `Missing Docker. Install it now? (y/n)` + - If user answers `y`: + - macOS: run `brew install --cask docker`, then tell the user to open Docker Desktop + - Linux: run `curl -fsSL https://get.docker.com | sh` +- Installed but daemon not running → "Please start Docker Desktop (macOS) or `sudo systemctl start docker` (Linux)." + +**git** — if missing: +- `brew install git` (macOS) or `sudo apt install git` (Linux) + +### Optional and Path-Dependent Tools + +**gh CLI (GitHub CLI):** +- If present and authenticated → enables **zero-interaction GHCR push** +- `build-push.mjs` auto-detects `gh auth status` and uses `gh auth token` to login to `ghcr.io` +- GHCR push alone is not enough for Sealos. For private GHCR packages, the deploy step must create an image pull Secret using the local `gh` CLI session. +- `sealos-deploy` should never ask the user to type registry host/username/password when `gh auth status` is already available locally. +- Missing `gh` is **not** a universal preflight failure +- `gh` becomes mandatory only when the selected image destination is GHCR +- If `gh` is missing, ask: + - `Missing gh. Install it now? (y/n)` + - If user answers `y`: + - macOS: run `brew install gh` + - Debian/Ubuntu: run `sudo apt install gh` +- Do not trigger `gh auth login` during environment detection +- Only trigger `gh auth login` later if the run actually reaches a GHCR push path chosen by the user + +**Docker Hub login session:** +- Needed only when the selected image destination is Docker Hub +- Docker Hub path assumes the pushed image will be public at deploy time +- Private Docker Hub images are out of scope for `sealos-deploy` pull-secret automation +- `docker login` may need to be run manually by the user in another terminal +- Do not treat a missing Docker Hub login as a universal preflight blocker +- Ask for the registry destination later in Phase 4, then enforce the matching login path + +**Node.js:** +- If missing, no problem. Pipeline uses fallback mode: + - `score-model.mjs` → AI reads files and applies scoring rules directly + - `detect-image.mjs` → AI runs curl commands for Docker Hub / GHCR API + - `build-push.mjs` → AI runs `docker buildx` commands directly + - `sealos-auth.mjs` → AI runs curl to exchange token for kubeconfig (workspace list/switch not available in fallback mode) + +**Python:** +- If missing, Sealos template validation (Phase 5) uses AI self-check instead of `quality_gate.py` + +**kubectl (required for in-place updates):** +- Needed for updating already-deployed apps with `kubectl set image` and `kubectl rollout` +- If `kubectl` is missing, ask: + - `Missing kubectl. Install it now? (y/n)` + - If user answers `y`: + - macOS: run `brew install kubectl` + - Debian/Ubuntu: run `sudo apt install kubectl` +- If `kubectl` is available outside PATH, use the absolute path for all kubectl commands + +## Step 2: Capability Classification + +Before touching the project, classify findings into: +- immediate stop conditions +- warnings that may become blocking later +- optional accelerators + +### 2.1 Immediate Stop Conditions + +Stop before project inspection only when one of these is true: +- Sealos authentication is unavailable and cannot be completed +- Workspace selection is incomplete +- The user provided a GitHub URL and `git` is unavailable, so the repository cannot be cloned +- `curl` is unavailable, so auth and fallback API checks cannot run + +These are true entry blockers for a deploy run. + +### 2.2 Build-Path Warnings + +Detect these now and report them early, but do **not** stop the run yet: +- Docker CLI missing +- Docker daemon not running +- `gh` missing +- `gh auth status` failing +- Docker builder unavailable (`docker buildx version` or equivalent) +- Container registry connectivity looks unhealthy + +These findings become hard blockers only if the run later determines that local image build/push is required. + +### 2.3 Update-Path Warnings + +Detect these now and report them early, but do **not** stop a fresh deploy: +- `kubectl` missing +- kubeconfig present but unusable + +These become hard blockers only if the run enters UPDATE mode or needs rollout verification through kubectl. + +### 2.4 Early Reporting Rule + +At the end of preflight, explicitly tell the user: +- which items are ready +- which items are warnings only +- which later path each warning would block + +Example: +- "Docker is not ready. This will block Phase 4 local build, but we can still continue to detect whether an existing image can be reused." +- "`kubectl` is missing. Fresh deploy can continue, but UPDATE mode and rollout verification will be blocked until it is installed." + +## Step 3: Project Context + +**Execution order override:** Do **not** execute this section until Step 4 auth/workspace checks are complete. +Run **Step 4: Sealos Cloud Auth** first, satisfy the immediate stop conditions, then come back to Step 3. + +This section is intentionally documented here for readability, but it is operationally blocked behind Step 4. + +Determine what we're deploying and gather project information. + +### 2.1 Resolve Working Directory + +**A) User provided a GitHub URL:** +```bash +WORK_DIR=$(mktemp -d) +git clone --depth 1 "" "$WORK_DIR" +GITHUB_URL="" +``` + +**B) User provided a local path:** +```bash +WORK_DIR="" +``` + +**C) No input — deploy current project (most common):** +```bash +WORK_DIR="$(pwd)" +``` + +### 2.2 Git Repo Detection + +```bash +# Is it a git repo? +git -C "$WORK_DIR" rev-parse --is-inside-work-tree 2>/dev/null + +# Git metadata +git -C "$WORK_DIR" remote get-url origin 2>/dev/null # → GITHUB_URL (if github.com) +git -C "$WORK_DIR" branch --show-current 2>/dev/null # → BRANCH +git -C "$WORK_DIR" log --oneline -1 2>/dev/null # → latest commit +``` + +Record: +``` +PROJECT.work_dir = resolved path +PROJECT.is_git = true/false +PROJECT.github_url = "https://github.com/owner/repo" or empty +PROJECT.repo_name = basename of directory or parsed from URL +PROJECT.branch = current branch +``` + +If `PROJECT.github_url` exists, parse `owner` and `repo` for Phase 2 image detection. + +### 2.3 Read README + +README is the single most important file for understanding a project. Read it now. + +```bash +# Find README (case-insensitive) +ls "$WORK_DIR"/README* "$WORK_DIR"/readme* 2>/dev/null | head -1 +``` + +Read the README content and extract: +- **Project description** — what does this project do? +- **Tech stack** — language, framework, database +- **Run/build instructions** — how to build, what port it listens on +- **Docker references** — `docker run`, `docker pull`, image names (ghcr.io/..., dockerhub/...) +- **Environment variables** — any `.env` examples or config descriptions + +Record key findings in `PROJECT.readme_summary` for use in Phase 1 (assess) and Phase 2 (detect). + +This avoids re-reading README in every phase. The AI already has it in context. + +## Step 4: Sealos Cloud Auth (OAuth2 Device Grant Flow) + +This step must complete before Step 3 project context begins in practice. + +Uses RFC 8628 Device Authorization Grant — no token copy-paste needed. + +### 4.0 Region Selection + +Before auth, let the user choose which Sealos Cloud region to deploy to. + +Read the default region and available regions from config: +```bash +DEFAULT_REGION=$(jq -r '.default_region' "/config.json") +``` + +**Always ask the user to confirm or choose a region.** Present the regions from `config.json` and allow custom input: + +``` +Which Sealos Cloud region do you want to deploy to? + + 1. https://usw-1.sealos.io (default) + 2. https://gzg.sealos.run + 3. https://bja.sealos.run + 4. https://hzh.sealos.run + 5. Enter a custom region URL + +Default: https://usw-1.sealos.io +``` + +The region list comes from `config.json` `regions` array. If `regions` is not present, show only `default_region`. + +If the user has an existing `~/.sealos/auth.json`, read the previously used region and offer it as an option: +```bash +PREV_REGION=$(jq -r '.region // empty' ~/.sealos/auth.json 2>/dev/null) +``` + +If `PREV_REGION` exists and differs from `DEFAULT_REGION`, include it in the choices. + +Record the user's choice as `REGION` for use throughout the rest of this step and Phase 6. + +**If the user picks a different region than the existing `~/.sealos/auth.json`**, the existing kubeconfig is invalid — force re-authentication. + +### 4.1 Check auth status: + +**With Node.js:** +```bash +node "/scripts/sealos-auth.mjs" check +``` +Returns: `{ "authenticated": true/false, "kubeconfig_path": "...", "workspace": "ns-xxx" }` + +**Without Node.js:** +```bash +test -f ~/.sealos/kubeconfig && echo '{"authenticated":true}' || echo '{"authenticated":false}' +``` + +### 4.2 If not authenticated — Device Grant Login: + +**With Node.js (recommended):** +```bash +node "/scripts/sealos-auth.mjs" login [region-url] +``` + +If the script fails with `"error":"fetch failed"` or TLS/certificate error, retry with `--insecure`: +```bash +node "/scripts/sealos-auth.mjs" login [region-url] --insecure +``` + +If it still fails, fall back to curl (see below). **Once you switch to curl, use curl for the entire remaining flow** — do NOT mix curl and Node.js mid-flow. + +The script will: +1. `POST /api/auth/oauth2/device` with the `client_id` from `config.json` +2. Output a verification URL and user code to stderr +3. Auto-open the browser for the user +4. Poll `POST /api/auth/oauth2/token` every 5s until approved +5. Exchange access_token for regional token via `POST /api/auth/regionToken` +6. Save kubeconfig to `~/.sealos/kubeconfig` (mode 0600) +7. Save access_token, regional_token, and current_workspace to `~/.sealos/auth.json` + +**Important — AI must always show the clickable URL to the user:** +Even though the script attempts to auto-open the browser, it may fail (e.g., headless environment, SSH session, sandbox restrictions). +After running the script, YOU (the AI) must extract the verification URL from stderr output and display it as a clickable link to the user: +``` +Please click the link below to authorize: + +Authorization code: +``` +This ensures the user can always complete authorization regardless of whether auto-open succeeded. + +Stdout outputs JSON result: `{ "kubeconfig_path": "...", "region": "...", "workspace": "ns-xxx" }` + +**Without Node.js (curl fallback):** + +**Important: once you enter the curl path, complete ALL steps with curl. Do NOT switch to Node.js or Python mid-flow.** + +First, read constants from `/config.json`: +```bash +# Read skill constants (client_id, default_region) +CLIENT_ID=$(jq -r '.client_id' "/config.json") +DEFAULT_REGION=$(jq -r '.default_region' "/config.json") +``` + +Step 1 — Request device authorization: +```bash +REGION="${REGION:-$DEFAULT_REGION}" +DEVICE_RESP=$(curl -ksf -X POST "$REGION/api/auth/oauth2/device" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code") +``` +Note: `-k` skips TLS verification for self-signed certificates. + +Extract fields from response: +```bash +DEVICE_CODE=$(echo "$DEVICE_RESP" | grep -o '"device_code":"[^"]*"' | cut -d'"' -f4) +USER_CODE=$(echo "$DEVICE_RESP" | grep -o '"user_code":"[^"]*"' | cut -d'"' -f4) +VERIFY_URL=$(echo "$DEVICE_RESP" | grep -o '"verification_uri_complete":"[^"]*"' | cut -d'"' -f4) +INTERVAL=$(echo "$DEVICE_RESP" | grep -o '"interval":[0-9]*' | cut -d: -f2) +INTERVAL=${INTERVAL:-5} +``` + +Step 2 — Show the authorization link to user: +``` +Please click the link below to authorize: +$VERIFY_URL +Authorization code: $USER_CODE +``` +If `VERIFY_URL` is empty, use `verification_uri` instead and show the user code separately. + +Step 3 — Poll for token: +```bash +while true; do + sleep "$INTERVAL" + TOKEN_RESP=$(curl -ksf -X POST "$REGION/api/auth/oauth2/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$DEVICE_CODE") + + # Check for access_token in response + ACCESS_TOKEN=$(echo "$TOKEN_RESP" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4) + if [ -n "$ACCESS_TOKEN" ]; then + break + fi + + # Check for terminal errors + ERROR=$(echo "$TOKEN_RESP" | grep -o '"error":"[^"]*"' | cut -d'"' -f4) + case "$ERROR" in + authorization_pending) continue ;; + slow_down) INTERVAL=$((INTERVAL + 5)) ;; + access_denied) echo "User denied authorization"; exit 1 ;; + expired_token) echo "Device code expired"; exit 1 ;; + *) echo "Error: $ERROR"; exit 1 ;; + esac +done +``` + +Step 4 — Exchange token for regional token + kubeconfig (still curl): +```bash +REGION_RESP=$(curl -ksf -X POST "$REGION/api/auth/regionToken" \ + -H "Authorization: $ACCESS_TOKEN" \ + -H "Content-Type: application/json") +# Server returns { data: { token, kubeconfig } } +REGIONAL_TOKEN=$(echo "$REGION_RESP" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4) +# Extract kubeconfig — it's a multi-line YAML value inside JSON +mkdir -p ~/.sealos +node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf-8')); process.stdout.write(d.data.kubeconfig)" <<< "$REGION_RESP" > ~/.sealos/kubeconfig 2>/dev/null \ + || python3 -c "import sys,json; print(json.load(sys.stdin)['data']['kubeconfig'])" <<< "$REGION_RESP" > ~/.sealos/kubeconfig +chmod 600 ~/.sealos/kubeconfig +``` +Note: kubeconfig is multi-line YAML embedded in JSON — simple grep won't work. Use node/python one-liner to extract it. Save auth metadata with tokens: +```bash +cat > ~/.sealos/auth.json << EOF +{"region":"$REGION","access_token":"$ACCESS_TOKEN","regional_token":"$REGIONAL_TOKEN","authenticated_at":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","auth_method":"oauth2_device_grant"} +EOF +chmod 600 ~/.sealos/auth.json +``` + +### 4.3 Workspace Selection (every deploy) + +After auth is confirmed, **always** let the user choose which workspace to deploy to. The last-used workspace is the default. + +**With Node.js:** +```bash +node "/scripts/sealos-auth.mjs" list +``` +Returns: +```json +{ + "current": "ns-abc", + "workspaces": [ + { "uid": "...", "id": "ns-abc", "teamName": "My Team", "role": 0, "nstype": 1 }, + { "uid": "...", "id": "ns-def", "teamName": "Dev Team", "role": 0, "nstype": 0 }, + { "uid": "...", "id": "ns-ghi", "teamName": "Staging", "role": 2, "nstype": 0 } + ] +} +``` + +Present the workspace list to the user. **Put the `current` workspace first**, mark it as last used: + +``` +Which workspace do you want to deploy to? + + 1. ns-abc — My Team ← current + 2. ns-def — Dev Team + 3. ns-ghi — Staging + +Default: ns-abc (My Team) +``` + +Display format is `id — teamName`. The `current` field from the JSON indicates the last-used workspace — always list it first. + +- If the user picks the same workspace as `current` → no action needed, kubeconfig is already valid. +- If the user picks a different workspace → switch: + +```bash +node "/scripts/sealos-auth.mjs" switch +``` + +This updates `~/.sealos/kubeconfig` and records the new workspace as `current_workspace` in `auth.json` for next time. + +**Without Node.js (curl fallback):** + +List workspaces: +```bash +NS_RESP=$(curl -ksf "$REGION/api/auth/namespace/list" \ + -H "Authorization: $REGIONAL_TOKEN") +``` + +Parse and present options to user. If the user picks a different workspace: +```bash +SWITCH_RESP=$(curl -ksf -X POST "$REGION/api/auth/namespace/switch" \ + -H "Authorization: $REGIONAL_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"ns_uid\":\"$TARGET_UID\"}") +NEW_TOKEN=$(echo "$SWITCH_RESP" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4) + +# Get new kubeconfig +KC_RESP=$(curl -ksf "$REGION/api/auth/getKubeconfig" \ + -H "Authorization: $NEW_TOKEN") +node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf-8')); process.stdout.write(d.data.kubeconfig)" <<< "$KC_RESP" > ~/.sealos/kubeconfig 2>/dev/null \ + || python3 -c "import sys,json; print(json.load(sys.stdin)['data']['kubeconfig'])" <<< "$KC_RESP" > ~/.sealos/kubeconfig +chmod 600 ~/.sealos/kubeconfig + +# Update auth.json with new token +REGIONAL_TOKEN="$NEW_TOKEN" +``` + +**If only one workspace exists**, skip the selection prompt and use it directly. + +## Step 5: Ready + +Only reach this section after: +- Step 1 environment detection/checks passed +- Step 2 capability classification completed +- Step 4 auth/workspace checks passed +- And only then Step 3 project context was collected + +Report to user with a short readiness summary. This is a user-facing status snapshot, not a full artifact dump. +Keep it focused on the key capabilities and blockers only. + +Do **not** add a "full details" section in the default output. + +Recommended format: + +``` +Project: + ✓ () + ✓ git: + ✓ README: + +Environment: + ○ Docker (or: ✗ Docker — local build path currently blocked) + ✓ git + ○ Node.js (or: ✗ Node.js — using AI fallback mode) + ○ Python (or: ✗ Python — template validation via AI) + ○ kubectl (or: ✗ kubectl — update/rollout path blocked) + ○ gh (or: ✗ gh CLI — local GHCR push path blocked) + +Auth: + ✓ Sealos Cloud () + ✓ Workspace: () +``` + +If Docker, `gh`, buildx, or registry connectivity are not ready, report them now as path-specific warnings. Only upgrade them to hard blockers if Phase 2/3 confirms that local build/push is required. + +Output rules: +- Show only the high-signal items a user needs to decide whether to continue +- Do not print raw command output or exhaustive diagnostics in the normal summary +- If a capability is missing, explain briefly which later path it blocks +- Prefer one-line project identification plus compact Environment/Auth sections over long prose + +Record `ENV` and `PROJECT` for subsequent phases → proceed to `modules/pipeline.md`. diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/analysis.schema.json b/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/analysis.schema.json new file mode 100644 index 00000000..29c940ab --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/analysis.schema.json @@ -0,0 +1,236 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://skills.sh/sealos-deploy/schemas/analysis.schema.json", + "title": "Sealos Deploy Analysis Artifact", + "type": "object", + "additionalProperties": false, + "required": [ + "generated_at", + "project", + "score", + "language", + "all_languages", + "framework", + "package_manager", + "port", + "databases", + "runtime_version", + "env_vars", + "has_dockerfile", + "complexity_tier", + "image_ref" + ], + "properties": { + "generated_at": { + "type": "string", + "format": "date-time" + }, + "project": { + "type": "object", + "additionalProperties": false, + "required": [ + "github_url", + "work_dir", + "repo_name", + "branch" + ], + "properties": { + "github_url": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + }, + "work_dir": { + "type": "string", + "minLength": 1 + }, + "repo_name": { + "type": "string", + "minLength": 1 + }, + "branch": { + "anyOf": [ + { "type": "string", "minLength": 1 }, + { "type": "null" } + ] + } + } + }, + "score": { + "type": "object", + "additionalProperties": false, + "required": [ + "total", + "verdict", + "dimensions" + ], + "properties": { + "total": { + "type": "integer", + "minimum": 0, + "maximum": 12 + }, + "verdict": { + "type": "string", + "minLength": 1 + }, + "dimensions": { + "type": "object", + "additionalProperties": false, + "required": [ + "statelessness", + "config", + "scalability", + "startup", + "observability", + "boundaries" + ], + "properties": { + "statelessness": { "type": "integer", "minimum": 0, "maximum": 2 }, + "config": { "type": "integer", "minimum": 0, "maximum": 2 }, + "scalability": { "type": "integer", "minimum": 0, "maximum": 2 }, + "startup": { "type": "integer", "minimum": 0, "maximum": 2 }, + "observability": { "type": "integer", "minimum": 0, "maximum": 2 }, + "boundaries": { "type": "integer", "minimum": 0, "maximum": 2 } + } + } + } + }, + "language": { + "type": "string", + "enum": [ + "go", + "rust", + "java", + "node", + "python", + "php", + "ruby", + "dotnet" + ] + }, + "all_languages": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "go", + "rust", + "java", + "node", + "python", + "php", + "ruby", + "dotnet" + ] + } + }, + "framework": { + "type": "string", + "minLength": 1 + }, + "package_manager": { + "type": "string", + "enum": [ + "npm", + "yarn", + "pnpm", + "bun", + "pip", + "pipenv", + "go", + "cargo", + "maven", + "gradle", + "composer", + "bundler" + ] + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "databases": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "postgres", + "mysql", + "mongodb", + "redis", + "sqlite" + ] + } + }, + "runtime_version": { + "type": "object", + "minProperties": 2, + "required": [ + "source" + ], + "properties": { + "source": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "env_vars": { + "type": "object", + "patternProperties": { + "^[A-Z][A-Z0-9_]*$": { + "type": "object", + "additionalProperties": false, + "required": [ + "category" + ], + "properties": { + "category": { + "type": "string", + "enum": [ + "auto", + "required", + "optional" + ] + }, + "description": { + "type": "string", + "minLength": 1 + }, + "default": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + "has_dockerfile": { + "type": "boolean" + }, + "complexity_tier": { + "type": "string", + "enum": [ + "L1", + "L2", + "L3" + ] + }, + "image_ref": { + "anyOf": [ + { "type": "string", "minLength": 1 }, + { "type": "null" } + ] + } + } +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/build-result.schema.json b/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/build-result.schema.json new file mode 100644 index 00000000..fe74d32c --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/build-result.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://skills.sh/sealos-deploy/schemas/build-result.schema.json", + "title": "Sealos Deploy Build Result Artifact", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "outcome", + "registry", + "build", + "push", + "finished_at" + ], + "properties": { + "outcome": { + "const": "success" + }, + "registry": { + "type": "string", + "enum": [ + "ghcr", + "dockerhub" + ] + }, + "build": { + "type": "object", + "additionalProperties": false, + "required": [ + "image_name", + "started_at" + ], + "properties": { + "image_name": { + "type": "string", + "pattern": "^[a-z0-9_.-]+$" + }, + "started_at": { + "type": "string", + "format": "date-time" + } + } + }, + "push": { + "type": "object", + "additionalProperties": false, + "required": [ + "remote_image", + "pushed_at" + ], + "properties": { + "remote_image": { + "type": "string", + "minLength": 1 + }, + "pushed_at": { + "type": "string", + "format": "date-time" + } + } + }, + "finished_at": { + "type": "string", + "format": "date-time" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "outcome", + "registry", + "build", + "push", + "error", + "finished_at" + ], + "properties": { + "outcome": { + "const": "failed" + }, + "registry": { + "type": "string", + "enum": [ + "ghcr", + "dockerhub" + ] + }, + "build": { + "type": "object", + "additionalProperties": false, + "required": [ + "image_name", + "started_at" + ], + "properties": { + "image_name": { + "type": "string", + "pattern": "^[a-z0-9_.-]+$" + }, + "started_at": { + "type": "string", + "format": "date-time" + } + } + }, + "push": { + "type": "object", + "additionalProperties": false, + "required": [ + "remote_image" + ], + "properties": { + "remote_image": { + "type": "string", + "minLength": 1 + } + } + }, + "error": { + "type": "string", + "minLength": 1 + }, + "finished_at": { + "type": "string", + "format": "date-time" + } + } + } + ] +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/config.schema.json b/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/config.schema.json new file mode 100644 index 00000000..9ed32397 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/config.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://skills.sh/sealos-deploy/schemas/config.schema.json", + "title": "Sealos Deploy Project Config", + "type": "object", + "additionalProperties": false, + "properties": { + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "node_version": { + "type": "string", + "pattern": "^\\d+(?:\\.\\d+){0,2}$" + }, + "start_command": { + "type": "string", + "minLength": 1 + }, + "build_command": { + "type": "string", + "minLength": 1 + }, + "system_deps": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "base_image": { + "type": "string", + "minLength": 1 + }, + "env_overrides": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" } + ] + } + }, + "skip_phases": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "assess", + "detect-image", + "dockerfile", + "build", + "template", + "deploy" + ] + }, + "uniqueItems": true + } + } +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/state.schema.json b/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/state.schema.json new file mode 100644 index 00000000..9075fcd2 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/schemas/state.schema.json @@ -0,0 +1,287 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://skills.sh/sealos-deploy/schemas/state.schema.json", + "title": "Sealos Deploy State Artifact", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "last_deploy", + "history" + ], + "properties": { + "version": { + "const": "1.0" + }, + "last_deploy": { + "type": "object", + "additionalProperties": false, + "required": [ + "app_name", + "app_host", + "namespace", + "region", + "image", + "docker_hub_user", + "repo_name", + "url", + "deployed_at", + "last_updated_at" + ], + "properties": { + "app_name": { + "type": "string", + "minLength": 1 + }, + "app_host": { + "type": "string", + "minLength": 1 + }, + "namespace": { + "type": "string", + "minLength": 1 + }, + "region": { + "type": "string", + "pattern": "^[a-z0-9.-]+$" + }, + "image": { + "type": "string", + "minLength": 1 + }, + "docker_hub_user": { + "anyOf": [ + { "type": "string", "minLength": 1 }, + { "type": "null" } + ] + }, + "repo_name": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "pattern": "^https://" + }, + "deployed_at": { + "type": "string", + "format": "date-time" + }, + "last_updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "history": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "at", + "action", + "image", + "method", + "status" + ], + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "action": { + "const": "deploy" + }, + "image": { + "type": "string", + "minLength": 1 + }, + "method": { + "type": "string", + "enum": [ + "template-api", + "kubectl-apply" + ] + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed" + ] + }, + "note": { + "type": "string", + "minLength": 1 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "at", + "action", + "previous_image", + "image", + "method", + "status" + ], + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "action": { + "const": "set-image" + }, + "previous_image": { + "type": "string", + "minLength": 1 + }, + "image": { + "type": "string", + "minLength": 1 + }, + "method": { + "const": "kubectl-set-image" + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed" + ] + }, + "note": { + "type": "string", + "minLength": 1 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "at", + "action", + "changes", + "method", + "status" + ], + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "action": { + "const": "set-env" + }, + "changes": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "method": { + "const": "kubectl-set-env" + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed" + ] + }, + "note": { + "type": "string", + "minLength": 1 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "at", + "action", + "changes", + "method", + "status" + ], + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "action": { + "const": "patch" + }, + "changes": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "method": { + "const": "kubectl-patch" + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed" + ] + }, + "note": { + "type": "string", + "minLength": 1 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "at", + "action", + "method", + "status" + ], + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "action": { + "const": "restart" + }, + "method": { + "const": "kubectl-rollout-restart" + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed" + ] + }, + "note": { + "type": "string", + "minLength": 1 + } + } + } + ] + } + } + } +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/artifact-validator.mjs b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/artifact-validator.mjs new file mode 100644 index 00000000..a66e423f --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/artifact-validator.mjs @@ -0,0 +1,372 @@ +#!/usr/bin/env node + +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const SCHEMA_DIR = path.join(__dirname, '..', 'schemas') + +const SCHEMA_FILES = { + config: 'config.schema.json', + analysis: 'analysis.schema.json', + 'build-result': 'build-result.schema.json', + state: 'state.schema.json', +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isIsoDateTime(value) { + return typeof value === 'string' && !Number.isNaN(Date.parse(value)) +} + +function formatPath(pointer, suffix = '') { + return `${pointer}${suffix}` +} + +function pushError(errors, pointer, message) { + errors.push({ path: pointer, message }) +} + +function validateType(expectedType, value) { + switch (expectedType) { + case 'object': + return isPlainObject(value) + case 'array': + return Array.isArray(value) + case 'string': + return typeof value === 'string' + case 'integer': + return Number.isInteger(value) + case 'number': + return typeof value === 'number' && Number.isFinite(value) + case 'boolean': + return typeof value === 'boolean' + case 'null': + return value === null + default: + return false + } +} + +function validateAgainstSchema(schema, value, pointer = '$', errors = []) { + if (schema.anyOf) { + const branches = schema.anyOf.map((candidate) => { + const branchErrors = [] + validateAgainstSchema(candidate, value, pointer, branchErrors) + return branchErrors + }) + + if (!branches.some((branchErrors) => branchErrors.length === 0)) { + pushError(errors, pointer, 'does not match any allowed schema') + } + return errors + } + + if (schema.oneOf) { + const branches = schema.oneOf.map((candidate) => { + const branchErrors = [] + validateAgainstSchema(candidate, value, pointer, branchErrors) + return branchErrors + }) + const validCount = branches.filter((branchErrors) => branchErrors.length === 0).length + if (validCount !== 1) { + pushError(errors, pointer, `expected exactly one schema match, got ${validCount}`) + } + return errors + } + + if (Object.prototype.hasOwnProperty.call(schema, 'const') && value !== schema.const) { + pushError(errors, pointer, `must equal ${JSON.stringify(schema.const)}`) + return errors + } + + if (schema.enum && !schema.enum.includes(value)) { + pushError(errors, pointer, `must be one of ${schema.enum.join(', ')}`) + return errors + } + + if (schema.type && !validateType(schema.type, value)) { + pushError(errors, pointer, `must be of type ${schema.type}`) + return errors + } + + switch (schema.type) { + case 'object': + validateObjectSchema(schema, value, pointer, errors) + break + case 'array': + validateArraySchema(schema, value, pointer, errors) + break + case 'string': + validateStringSchema(schema, value, pointer, errors) + break + case 'integer': + case 'number': + validateNumberSchema(schema, value, pointer, errors) + break + default: + break + } + + return errors +} + +function validateObjectSchema(schema, value, pointer, errors) { + const keys = Object.keys(value) + + if (schema.required) { + for (const requiredKey of schema.required) { + if (!Object.prototype.hasOwnProperty.call(value, requiredKey)) { + pushError(errors, pointer, `missing required property ${requiredKey}`) + } + } + } + + if (typeof schema.minProperties === 'number' && keys.length < schema.minProperties) { + pushError(errors, pointer, `must have at least ${schema.minProperties} properties`) + } + + const properties = schema.properties || {} + const patternProperties = schema.patternProperties || {} + const compiledPatterns = Object.entries(patternProperties).map(([pattern, childSchema]) => ({ + regex: new RegExp(pattern), + schema: childSchema, + })) + + for (const [key, childValue] of Object.entries(value)) { + const childPointer = formatPath(pointer, `.${key}`) + + if (Object.prototype.hasOwnProperty.call(properties, key)) { + validateAgainstSchema(properties[key], childValue, childPointer, errors) + continue + } + + const matched = compiledPatterns.filter(({ regex }) => regex.test(key)) + if (matched.length > 0) { + for (const candidate of matched) { + validateAgainstSchema(candidate.schema, childValue, childPointer, errors) + } + continue + } + + if (schema.additionalProperties === false) { + pushError(errors, childPointer, 'is not allowed') + continue + } + + if (isPlainObject(schema.additionalProperties)) { + validateAgainstSchema(schema.additionalProperties, childValue, childPointer, errors) + } + } +} + +function validateArraySchema(schema, value, pointer, errors) { + if (typeof schema.minItems === 'number' && value.length < schema.minItems) { + pushError(errors, pointer, `must contain at least ${schema.minItems} items`) + } + + if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) { + pushError(errors, pointer, `must contain at most ${schema.maxItems} items`) + } + + if (schema.uniqueItems) { + const seen = new Set() + for (let index = 0; index < value.length; index++) { + const encoded = JSON.stringify(value[index]) + if (seen.has(encoded)) { + pushError(errors, formatPath(pointer, `[${index}]`), 'must be unique') + } + seen.add(encoded) + } + } + + if (schema.items) { + for (let index = 0; index < value.length; index++) { + validateAgainstSchema(schema.items, value[index], formatPath(pointer, `[${index}]`), errors) + } + } +} + +function validateStringSchema(schema, value, pointer, errors) { + if (typeof schema.minLength === 'number' && value.length < schema.minLength) { + pushError(errors, pointer, `must be at least ${schema.minLength} characters long`) + } + + if (schema.pattern && !(new RegExp(schema.pattern).test(value))) { + pushError(errors, pointer, `must match pattern ${schema.pattern}`) + } + + if (schema.format === 'date-time' && !isIsoDateTime(value)) { + pushError(errors, pointer, 'must be a valid ISO 8601 date-time') + } +} + +function validateNumberSchema(schema, value, pointer, errors) { + if (typeof schema.minimum === 'number' && value < schema.minimum) { + pushError(errors, pointer, `must be >= ${schema.minimum}`) + } + + if (typeof schema.maximum === 'number' && value > schema.maximum) { + pushError(errors, pointer, `must be <= ${schema.maximum}`) + } +} + +function loadSchema(kind) { + const fileName = SCHEMA_FILES[kind] + if (!fileName) { + throw new Error(`Unknown artifact kind: ${kind}`) + } + + return JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, fileName), 'utf-8')) +} + +function validateAnalysisSemantics(data, errors) { + if (!data.all_languages.includes(data.language)) { + pushError(errors, '$.all_languages', 'must include the primary language') + } + + const dimensionTotal = Object.values(data.score.dimensions).reduce((sum, value) => sum + value, 0) + if (data.score.total !== dimensionTotal) { + pushError(errors, '$.score.total', `must equal the sum of score.dimensions (${dimensionTotal})`) + } + + if (!Object.prototype.hasOwnProperty.call(data.runtime_version, data.language)) { + pushError(errors, '$.runtime_version', `must include a version field for primary language ${data.language}`) + } + + if (typeof data.image_ref === 'string' && !data.image_ref.includes(':')) { + pushError(errors, '$.image_ref', 'must include an explicit image tag') + } +} + +function validateBuildResultSemantics(data, errors) { + const startedAt = Date.parse(data.build.started_at) + const finishedAt = Date.parse(data.finished_at) + + if (!Number.isNaN(startedAt) && !Number.isNaN(finishedAt) && finishedAt < startedAt) { + pushError(errors, '$.finished_at', 'must not be earlier than build.started_at') + } + + if (data.registry === 'ghcr' && !data.push.remote_image.startsWith('ghcr.io/')) { + pushError(errors, '$.push.remote_image', 'must be a GHCR image when registry is ghcr') + } + + if (data.registry === 'dockerhub' && data.push.remote_image.startsWith('ghcr.io/')) { + pushError(errors, '$.push.remote_image', 'must not be a GHCR image when registry is dockerhub') + } + + if (!data.push.remote_image.includes(':')) { + pushError(errors, '$.push.remote_image', 'must include an explicit image tag') + } +} + +function validateStateSemantics(data, errors) { + const { last_deploy: lastDeploy, history } = data + + if (history[0]?.action !== 'deploy') { + pushError(errors, '$.history[0].action', 'the first history entry must be deploy') + } + + if (history[0]?.status !== 'success') { + pushError(errors, '$.history[0].status', 'the first history entry must be successful') + } + + const deployedAt = Date.parse(lastDeploy.deployed_at) + const updatedAt = Date.parse(lastDeploy.last_updated_at) + if (!Number.isNaN(deployedAt) && !Number.isNaN(updatedAt) && updatedAt < deployedAt) { + pushError(errors, '$.last_deploy.last_updated_at', 'must not be earlier than deployed_at') + } + + try { + const host = new URL(lastDeploy.url).hostname + if (!host.endsWith(`.${lastDeploy.region}`)) { + pushError(errors, '$.last_deploy.url', 'hostname must end with .') + } + } catch { + pushError(errors, '$.last_deploy.url', 'must be a valid https URL') + } + + let previousAt = null + let latestSuccessfulImage = null + for (let index = 0; index < history.length; index++) { + const entry = history[index] + const at = Date.parse(entry.at) + + if (previousAt !== null && !Number.isNaN(at) && at < previousAt) { + pushError(errors, `$.history[${index}].at`, 'must be in non-decreasing chronological order') + } + if (!Number.isNaN(at)) { + previousAt = at + } + + if (entry.action === 'set-image' && entry.image === entry.previous_image) { + pushError(errors, `$.history[${index}].image`, 'must differ from previous_image for set-image actions') + } + + if ((entry.action === 'deploy' || entry.action === 'set-image') && entry.status === 'success') { + latestSuccessfulImage = entry.image + } + } + + if (latestSuccessfulImage && latestSuccessfulImage !== lastDeploy.image) { + pushError(errors, '$.last_deploy.image', 'must match the latest successful image-changing history entry') + } +} + +const SEMANTIC_VALIDATORS = { + config: () => {}, + analysis: validateAnalysisSemantics, + 'build-result': validateBuildResultSemantics, + state: validateStateSemantics, +} + +export function inferArtifactKind(filePath) { + const baseName = path.basename(filePath) + switch (baseName) { + case 'config.json': + return 'config' + case 'analysis.json': + return 'analysis' + case 'build-result.json': + return 'build-result' + case 'state.json': + return 'state' + default: + return null + } +} + +export function validateArtifactData(kind, data) { + const schema = loadSchema(kind) + const errors = [] + + validateAgainstSchema(schema, data, '$', errors) + if (errors.length === 0) { + SEMANTIC_VALIDATORS[kind](data, errors) + } + + return { + kind, + valid: errors.length === 0, + errors, + } +} + +export function validateArtifactFile(kind, filePath) { + const raw = fs.readFileSync(filePath, 'utf-8') + let data + try { + data = JSON.parse(raw) + } catch (error) { + return { + kind, + valid: false, + errors: [{ path: '$', message: `invalid JSON: ${error.message}` }], + } + } + + return validateArtifactData(kind, data) +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/build-push.mjs b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/build-push.mjs new file mode 100644 index 00000000..b0e4e1f7 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/build-push.mjs @@ -0,0 +1,400 @@ +#!/usr/bin/env node + +/** + * Docker Build & Push (GHCR + Docker Hub) + * + * Builds a Docker image for linux/amd64 and pushes to a container registry. + * Automatically selects the best registry: GHCR (via gh CLI) > Docker Hub. + * + * Usage: + * node build-push.mjs # auto-detect registry + * node build-push.mjs --registry ghcr + * node build-push.mjs --registry dockerhub --user + * + * Output (JSON): + * { "success": true, "image": "ghcr.io/owner/repo:20260304-143022", "registry": "ghcr" } + * { "success": false, "error": "build failed: ..." } + */ + +import { execFileSync, execSync } from 'child_process' +import fs from 'fs' +import path from 'path' +import { validateArtifactData } from './artifact-validator.mjs' +import { ensureGhScopesWithPrompt, hasGhCli, run } from './gh-auth-utils.mjs' + +// ── Helpers ─────────────────────────────────────────────── + +function getDateTag () { + const d = new Date() + const date = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}` + const time = `${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}` + return `${date}-${time}` +} + +function runFile (command, args, opts = {}) { + return execFileSync(command, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim() +} + +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function ensureBuildDir (workDir) { + const buildDir = path.join(workDir, '.sealos', 'build') + fs.mkdirSync(buildDir, { recursive: true }) + return buildDir +} + +function writeBuildResult (workDir, payload) { + const validation = validateArtifactData('build-result', payload) + if (!validation.valid) { + throw new Error(`Invalid build-result artifact: ${validation.errors.map(err => `${err.path} ${err.message}`).join('; ')}`) + } + + const buildDir = ensureBuildDir(workDir) + fs.writeFileSync( + path.join(buildDir, 'build-result.json'), + JSON.stringify(payload, null, 2), + ) +} + +// ── Registry Detection ─────────────────────────────────── + +function detectGhcr () { + try { + run('gh auth status') + const user = run('gh api user -q .login') + if (!user) return null + return { registry: 'ghcr', user } + } catch { + return null + } +} + +function promptGhLogin () { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return { + ok: false, + error: 'gh CLI is installed but not authenticated, and interactive login is not available in this terminal. Run: gh auth login', + } + } + + console.error('gh CLI is installed but not authenticated. Opening `gh auth login` for GHCR access...') + + try { + execSync('gh auth login', { stdio: 'inherit' }) + } catch { + return { + ok: false, + error: 'gh auth login was not completed. GHCR push requires a successful GitHub CLI login.', + } + } + + const ghcr = detectGhcr() + if (!ghcr) { + return { + ok: false, + error: 'gh auth login completed, but GitHub CLI is still not authenticated for GHCR use.', + } + } + + return { ok: true, registryInfo: ghcr } +} + +function loginGhcr (user) { + try { + const token = run('gh auth token') + execSync(`echo "${token}" | docker login ghcr.io -u ${user} --password-stdin`, { stdio: 'pipe' }) + return true + } catch (e) { + return false + } +} + +async function ensureGhcrRegistry ({ triggerLogin = false } = {}) { + const requiredScopes = ['write:packages'] + + if (!hasGhCli()) { + return { + ok: false, + error: 'gh CLI is not installed. Install it with: brew install gh && gh auth login', + } + } + + let ghcr = detectGhcr() + if (!ghcr && triggerLogin) { + const loginResult = promptGhLogin() + if (!loginResult.ok) return loginResult + ghcr = loginResult.registryInfo + } + + if (!ghcr) { + return { + ok: false, + error: 'gh CLI not authenticated. Run: gh auth login', + } + } + + const scopeCheck = await ensureGhScopesWithPrompt( + requiredScopes, + 'GHCR push and later private-image deploy', + ) + if (!scopeCheck.ok) { + return scopeCheck + } + + if (!loginGhcr(ghcr.user)) { + return { + ok: false, + error: 'Failed to login to ghcr.io via gh CLI', + } + } + + return { ok: true, registryInfo: ghcr } +} + +function getGhcrPackageVisibility (packageName) { + try { + return runFile('gh', ['api', `/user/packages/container/${packageName}`, '-q', '.visibility']) + } catch { + return null + } +} + +async function verifyGhcrPublicPull (user, packageName, tag) { + const visibility = getGhcrPackageVisibility(packageName) + const manifestUrl = `https://ghcr.io/v2/${user}/${packageName}/manifests/${tag}` + const acceptHeader = 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json' + + let lastStatus = null + let lastError = null + + for (let attempt = 0; attempt < 5; attempt++) { + try { + const tokenResponse = await fetch(`https://ghcr.io/token?scope=repository:${user}/${packageName}:pull`) + lastStatus = tokenResponse.status + + if (tokenResponse.ok) { + const tokenPayload = await tokenResponse.json() + if (tokenPayload.token) { + const manifestResponse = await fetch(manifestUrl, { + headers: { + Authorization: `Bearer ${tokenPayload.token}`, + Accept: acceptHeader, + }, + }) + + lastStatus = manifestResponse.status + if (manifestResponse.ok) { + return { ok: true, visibility } + } + + if (manifestResponse.status === 401 || manifestResponse.status === 403) { + break + } + } + } + } catch (error) { + lastError = error.message + } + + if (attempt < 4) { + await sleep(2000) + } + } + + return { ok: false, visibility, status: lastStatus, error: lastError } +} + +function formatGhcrPullabilityWarning (user, packageName, tag, verification) { + const settingsUrl = `https://github.com/users/${user}/packages/container/package/${packageName}/settings` + const visibility = verification.visibility || 'unknown' + const status = verification.status ? ` GHCR manifest check status: ${verification.status}.` : '' + const detail = verification.error ? ` Last check error: ${verification.error}.` : '' + return [ + `Built and pushed ${`ghcr.io/${user}/${packageName}:${tag}`}, but the image is not anonymously pullable from GHCR.`, + `Current package visibility: ${visibility}.${status}${detail}`, + `This is acceptable when the deploy step creates an image pull secret from local gh CLI credentials.`, + `If you want a public image instead, change the package visibility in GitHub Packages: ${settingsUrl}`, + ].join(' ') +} + +function detectDockerHub () { + try { + const info = run('docker info 2>/dev/null') + const match = info.match(/Username:\s*(\S+)/) + if (match) return { registry: 'dockerhub', user: match[1] } + return null + } catch { + return null + } +} + +/** + * Auto-detect the best available registry. + * Priority: GHCR (via gh CLI) > Docker Hub (already logged in) + */ +async function autoDetectRegistry () { + // 1. Try GHCR via gh CLI + if (hasGhCli()) { + const ghcrResult = await ensureGhcrRegistry({ triggerLogin: true }) + if (ghcrResult.ok) return ghcrResult.registryInfo + throw ghcrResult + } + + // 2. Try Docker Hub (already logged in) + const dockerhub = detectDockerHub() + if (dockerhub) return dockerhub + + // 3. Nothing available + return null +} + +// ── Build & Push ───────────────────────────────────────── + +async function buildAndPush (workDir, repoName, registryInfo) { + const tag = getDateTag() + const sanitized = repoName.toLowerCase().replace(/[^a-z0-9_.-]/g, '-') + const startedAt = new Date().toISOString() + + let remoteImage + if (registryInfo.registry === 'ghcr') { + remoteImage = `ghcr.io/${registryInfo.user}/${sanitized}:${tag}` + } else { + remoteImage = `${registryInfo.user}/${sanitized}:${tag}` + } + + const dockerfilePath = path.join(workDir, 'Dockerfile') + if (!fs.existsSync(dockerfilePath)) { + writeBuildResult(workDir, { + outcome: 'failed', + registry: registryInfo.registry, + build: { image_name: sanitized, started_at: startedAt }, + push: { remote_image: remoteImage }, + error: 'No Dockerfile found in work directory', + finished_at: new Date().toISOString(), + }) + return { success: false, error: 'No Dockerfile found in work directory' } + } + + try { + execSync( + `docker buildx build --platform linux/amd64 -t ${remoteImage} --push .`, + { cwd: workDir, stdio: 'pipe', timeout: 600000 }, + ) + + let warning = null + let requiresImagePullSecret = false + if (registryInfo.registry === 'ghcr') { + const pullVerification = await verifyGhcrPublicPull(registryInfo.user, sanitized, tag) + if (!pullVerification.ok) { + warning = formatGhcrPullabilityWarning(registryInfo.user, sanitized, tag, pullVerification) + requiresImagePullSecret = true + } + } + + writeBuildResult(workDir, { + outcome: 'success', + registry: registryInfo.registry, + build: { image_name: sanitized, started_at: startedAt }, + push: { remote_image: remoteImage, pushed_at: new Date().toISOString() }, + finished_at: new Date().toISOString(), + }) + + const result = { success: true, image: remoteImage, registry: registryInfo.registry } + if (warning) { + result.warning = warning + result.requires_image_pull_secret = requiresImagePullSecret + } + return result + } catch (e) { + const error = e.stderr?.toString() || e.message + writeBuildResult(workDir, { + outcome: 'failed', + registry: registryInfo.registry, + build: { image_name: sanitized, started_at: startedAt }, + push: { remote_image: remoteImage }, + error, + finished_at: new Date().toISOString(), + }) + return { success: false, error } + } +} + +// ── CLI ──────────────────────────────────────────────────── + +function parseArgs (argv) { + const args = argv.slice(2) + const parsed = { workDir: null, repoName: null, registry: null, user: null } + + const positional = [] + for (let i = 0; i < args.length; i++) { + if (args[i] === '--registry' && args[i + 1]) { + parsed.registry = args[++i] + } else if (args[i] === '--user' && args[i + 1]) { + parsed.user = args[++i] + } else { + positional.push(args[i]) + } + } + + parsed.workDir = positional[0] || null + parsed.repoName = positional[1] || null + return parsed +} + +const args = parseArgs(process.argv) + +if (!args.workDir || !args.repoName) { + console.error('Usage: node build-push.mjs [--registry ghcr|dockerhub] [--user ]') + process.exit(1) +} + +// Determine registry +let registryInfo + +if (args.registry === 'ghcr') { + // Explicit GHCR + const ghcrResult = await ensureGhcrRegistry({ triggerLogin: true }) + if (!ghcrResult.ok) { + console.log(JSON.stringify({ success: false, ...(ghcrResult.error ? ghcrResult : { error: 'Failed to prepare GHCR registry access' }) })) + process.exit(1) + } + registryInfo = ghcrResult.registryInfo +} else if (args.registry === 'dockerhub') { + // Explicit Docker Hub + if (!args.user) { + const dh = detectDockerHub() + if (!dh) { + console.log(JSON.stringify({ success: false, error: 'Not logged in to Docker Hub. Run: docker login' })) + process.exit(1) + } + registryInfo = dh + } else { + registryInfo = { registry: 'dockerhub', user: args.user } + } +} else { + // Auto-detect + try { + registryInfo = await autoDetectRegistry() + } catch (error) { + const structured = error && typeof error === 'object' && 'error' in error + console.log(JSON.stringify({ + success: false, + ...(structured ? error : { error: error.message }), + })) + process.exit(1) + } + if (!registryInfo) { + console.log(JSON.stringify({ + success: false, + error: 'No container registry available. Install gh CLI (brew install gh && gh auth login) or run docker login.', + })) + process.exit(1) + } +} + +const result = await buildAndPush(path.resolve(args.workDir), args.repoName, registryInfo) +console.log(JSON.stringify(result, null, 2)) + +if (!result.success) process.exit(1) diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/deploy-template.mjs b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/deploy-template.mjs new file mode 100644 index 00000000..0e35c1d5 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/deploy-template.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node + +/** + * Sealos Template Deploy + * + * Usage: + * node deploy-template.mjs [--dry-run] + * node deploy-template.mjs --args-json '{"KEY":"value"}' + * node deploy-template.mjs --args-file ./args.json + * + * Behavior: + * - Reads ~/.sealos/auth.json for the current region + * - Reads ~/.sealos/kubeconfig and sends it as encodeURIComponent(kubeconfig) + * - Posts the template YAML to: + * https://template./api/v2alpha/templates/raw + * - Prints a JSON result to stdout + */ + +import { existsSync, readFileSync } from 'fs' +import { homedir } from 'os' +import { basename, join, resolve } from 'path' + +const SEALOS_DIR = join(homedir(), '.sealos') +const AUTH_PATH = join(SEALOS_DIR, 'auth.json') +const KUBECONFIG_PATH = join(SEALOS_DIR, 'kubeconfig') + +function fail(message, extra = {}, code = 1) { + console.error(JSON.stringify({ error: message, ...extra }, null, 2)) + process.exit(code) +} + +function parseArgs(argv) { + const args = argv.slice(2) + let templatePath = null + let dryRun = false + let argsJson = null + let argsFile = null + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] + if (arg === '--dry-run') { + dryRun = true + continue + } + if (arg === '--args-json') { + argsJson = args[i + 1] + i += 1 + continue + } + if (arg === '--args-file') { + argsFile = args[i + 1] + i += 1 + continue + } + if (arg === '--help' || arg === '-h') { + printHelp() + process.exit(0) + } + if (!templatePath) { + templatePath = arg + continue + } + fail(`Unknown argument: ${arg}`) + } + + if (!templatePath) { + fail('Missing template path. Run with --help for usage.') + } + + if (argsJson && argsFile) { + fail('Use only one of --args-json or --args-file') + } + + return { + templatePath: resolve(process.cwd(), templatePath), + dryRun, + argsJson, + argsFile: argsFile ? resolve(process.cwd(), argsFile) : null, + } +} + +function printHelp() { + console.log(`Sealos Template Deploy + +Usage: + node deploy-template.mjs [--dry-run] + node deploy-template.mjs --args-json '{"KEY":"value"}' + node deploy-template.mjs --args-file ./args.json + +Examples: + node deploy-template.mjs .sealos/template/index.yaml --dry-run + node deploy-template.mjs template/myapp/index.yaml +`) +} + +function loadJson(filePath, label) { + if (!existsSync(filePath)) { + fail(`${label} not found`, { path: filePath }) + } + + try { + return JSON.parse(readFileSync(filePath, 'utf8')) + } catch (error) { + fail(`Failed to parse ${label}`, { path: filePath, details: error.message }) + } +} + +function normalizeRegion(region) { + const text = String(region || '').trim() + if (!text) { + fail('Auth file is missing region', { path: AUTH_PATH }) + } + + const normalized = text.replace(/\/+$/, '') + let url + try { + url = new URL(normalized) + } catch (error) { + fail('Invalid region URL in auth file', { region: text, details: error.message }) + } + + return { + region: url.toString().replace(/\/+$/, ''), + regionDomain: url.host, + deployUrl: `https://template.${url.host}/api/v2alpha/templates/raw`, + } +} + +function loadTemplate(templatePath) { + if (!existsSync(templatePath)) { + fail('Template file not found', { path: templatePath }) + } + + if (!/\.ya?ml$/i.test(basename(templatePath))) { + fail('Template path must point to a YAML file', { path: templatePath }) + } + + return readFileSync(templatePath, 'utf8') +} + +function loadDeployArgs({ argsJson, argsFile }) { + if (argsJson) { + try { + const parsed = JSON.parse(argsJson) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + fail('--args-json must be a JSON object') + } + return parsed + } catch (error) { + fail('Failed to parse --args-json', { details: error.message }) + } + } + + if (argsFile) { + const parsed = loadJson(argsFile, 'args file') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + fail('Args file must contain a JSON object', { path: argsFile }) + } + return parsed + } + + return {} +} + +function loadKubeconfig() { + if (!existsSync(KUBECONFIG_PATH)) { + fail('Kubeconfig not found', { path: KUBECONFIG_PATH }) + } + return readFileSync(KUBECONFIG_PATH, 'utf8') +} + +async function postTemplate({ deployUrl, kubeconfig, yaml, args, dryRun }) { + const response = await fetch(deployUrl, { + method: 'POST', + headers: { + Authorization: encodeURIComponent(kubeconfig), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yaml, + args, + dryRun, + }), + }) + + const text = await response.text() + let json = null + try { + json = text ? JSON.parse(text) : null + } catch { + json = null + } + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + json, + text, + } +} + +const input = parseArgs(process.argv) +const auth = loadJson(AUTH_PATH, 'auth file') +const { region, regionDomain, deployUrl } = normalizeRegion(auth.region) +const yaml = loadTemplate(input.templatePath) +const deployArgs = loadDeployArgs(input) +const kubeconfig = loadKubeconfig() + +try { + const result = await postTemplate({ + deployUrl, + kubeconfig, + yaml, + args: deployArgs, + dryRun: input.dryRun, + }) + + const payload = { + success: result.ok, + dry_run: input.dryRun, + region, + region_domain: regionDomain, + deploy_url: deployUrl, + template_path: input.templatePath, + args: deployArgs, + status: result.status, + response: result.json || result.text, + } + + if (!result.ok) { + console.error(JSON.stringify(payload, null, 2)) + process.exit(1) + } + + console.log(JSON.stringify(payload, null, 2)) +} catch (error) { + fail('Template API request failed', { + region, + region_domain: regionDomain, + deploy_url: deployUrl, + template_path: input.templatePath, + details: error.message, + }) +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/detect-image.mjs b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/detect-image.mjs new file mode 100644 index 00000000..801eff4f --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/detect-image.mjs @@ -0,0 +1,517 @@ +#!/usr/bin/env node + +/** + * Container Image Detection + * + * Detects existing container images for a GitHub project. + * Checks Docker Hub, GHCR, docker-compose, CI workflows, and README references. + * + * Usage: + * node detect-image.mjs [work-dir] # Remote repo + * node detect-image.mjs # Local project (auto-detect GitHub URL from git remote) + * + * Output (JSON): + * { "found": true, "image": "ghcr.io/zxh326/kite", "tag": "v0.4.0", "source": "ghcr-readme", "platforms": ["linux/amd64"] } + * { "found": false } + */ + +import fs from 'fs' +import path from 'path' +import { execSync } from 'child_process' + +// ── Infrastructure images to exclude ───────────────────── + +const INFRA_IMAGES = new Set([ + 'postgres', 'postgresql', 'mysql', 'mariadb', 'redis', 'mongo', 'mongodb', + 'memcached', 'elasticsearch', 'rabbitmq', 'minio', 'nats', 'zookeeper', + 'kafka', 'consul', 'vault', 'nginx', 'traefik', 'envoy', 'haproxy', +]) + +function isInfraImage (name) { + const lower = name.toLowerCase() + return INFRA_IMAGES.has(lower) || [...INFRA_IMAGES].some(inf => lower.startsWith(inf + ':') || lower === inf) +} + +// ── GitHub URL Parser ────────────────────────────────────── + +function parseGithubUrl (url) { + const sshMatch = url.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/) + if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] } + + const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/.*)?$/) + if (httpsMatch) return { owner: httpsMatch[1], repo: httpsMatch[2] } + + return null +} + +// ── Image Reference Parser ──────────────────────────────── + +function parseImageRef (raw) { + const s = raw.trim().replace(/^['"]|['"]$/g, '') + if (!s || s.startsWith('$') || s.startsWith('{')) return null + + // ghcr.io/owner/repo:tag + const ghcrMatch = s.match(/^ghcr\.io\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(?::([a-zA-Z0-9_.-]+))?$/) + if (ghcrMatch) return { registry: 'ghcr', owner: ghcrMatch[1], repo: ghcrMatch[2], tag: ghcrMatch[3] || null } + + // docker.io/owner/repo:tag or owner/repo:tag + const dockerMatch = s.match(/^(?:docker\.io\/)?([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(?::([a-zA-Z0-9_.-]+))?$/) + if (dockerMatch) { + const owner = dockerMatch[1] + const repo = dockerMatch[2] + if (owner === 'library') return null + return { registry: 'dockerhub', owner, repo, tag: dockerMatch[3] || null } + } + + return null +} + +// ── Docker Hub ───────────────────────────────────────────── + +async function checkDockerHub (namespace, repoName) { + const url = `https://hub.docker.com/v2/namespaces/${namespace}/repositories/${repoName}/tags?page_size=10` + try { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 10000) + const resp = await fetch(url, { signal: controller.signal }) + clearTimeout(timer) + + if (!resp.ok) return null + + const data = await resp.json() + if (!data.results || data.results.length === 0) return null + + const versionTagRe = /^v?\d+\.\d+/ + let bestTag = null + + for (const entry of data.results) { + const hasAmd64 = entry.images?.some(img => img.architecture === 'amd64') + if (!hasAmd64) continue + + const platforms = entry.images + .map(img => `${img.os}/${img.architecture}`) + .filter((v, i, a) => a.indexOf(v) === i) + + if (!bestTag || (versionTagRe.test(entry.name) && !versionTagRe.test(bestTag.tag))) { + bestTag = { tag: entry.name, platforms } + } + } + + if (!bestTag) return null + + return { source: 'dockerhub', image: `${namespace}/${repoName}`, tag: bestTag.tag, platforms: bestTag.platforms } + } catch { + return null + } +} + +// ── GHCR ─────────────────────────────────────────────────── + +async function checkGhcr (owner, repo) { + try { + // Get anonymous token + const tokenController = new AbortController() + const tokenTimer = setTimeout(() => tokenController.abort(), 10000) + const tokenResp = await fetch( + `https://ghcr.io/token?scope=repository:${owner}/${repo}:pull`, + { signal: tokenController.signal }, + ) + clearTimeout(tokenTimer) + + if (!tokenResp.ok) return null + const { token } = await tokenResp.json() + + // List tags + const tagsController = new AbortController() + const tagsTimer = setTimeout(() => tagsController.abort(), 10000) + const tagsResp = await fetch( + `https://ghcr.io/v2/${owner}/${repo}/tags/list`, + { headers: { Authorization: `Bearer ${token}` }, signal: tagsController.signal }, + ) + clearTimeout(tagsTimer) + + if (!tagsResp.ok) return null + const { tags } = await tagsResp.json() + if (!tags || tags.length === 0) return null + + // Prefer version tags + const versionTagRe = /^v?\d+\.\d+/ + const sorted = [...tags].sort((a, b) => { + const aVer = versionTagRe.test(a) ? 1 : 0 + const bVer = versionTagRe.test(b) ? 1 : 0 + return bVer - aVer + }) + + // Check manifest for amd64 + for (const tag of sorted.slice(0, 5)) { + try { + const mfController = new AbortController() + const mfTimer = setTimeout(() => mfController.abort(), 10000) + const mfResp = await fetch( + `https://ghcr.io/v2/${owner}/${repo}/manifests/${tag}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json', + }, + signal: mfController.signal, + }, + ) + clearTimeout(mfTimer) + + if (!mfResp.ok) continue + + const manifest = await mfResp.json() + let platforms = [] + + if (manifest.manifests) { + platforms = manifest.manifests + .filter(m => m.platform) + .map(m => `${m.platform.os}/${m.platform.architecture}`) + if (!platforms.some(p => p.includes('amd64'))) continue + } else { + platforms = ['linux/amd64'] + } + + return { source: 'ghcr', image: `ghcr.io/${owner}/${repo}`, tag, platforms } + } catch { + continue + } + } + + return null + } catch { + return null + } +} + +// ── Docker Compose Image Extraction ──────────────────────── + +function extractImagesFromCompose (workDir) { + const images = [] + const composeNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'] + + for (const name of composeNames) { + const p = path.join(workDir, name) + if (!fs.existsSync(p)) continue + + const content = fs.readFileSync(p, 'utf-8') + + // Match "image:" lines in docker-compose + for (const m of content.matchAll(/^\s*image:\s*['"]?([^\s'"#]+)['"]?/gm)) { + const ref = parseImageRef(m[1]) + if (!ref) continue + if (isInfraImage(ref.repo) || isInfraImage(ref.owner)) continue + images.push(ref) + } + + break // only read first compose file found + } + + // Deduplicate + const seen = new Set() + return images.filter(img => { + const key = `${img.registry}:${img.owner}/${img.repo}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +// ── CI Workflow Image Extraction ─────────────────────────── + +function extractImagesFromWorkflows (workDir) { + const images = [] + const workflowDir = path.join(workDir, '.github', 'workflows') + + if (!fs.existsSync(workflowDir)) return images + + let files + try { + files = fs.readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml')) + } catch { + return images + } + + for (const file of files) { + const content = fs.readFileSync(path.join(workflowDir, file), 'utf-8') + + // Match: docker push + for (const m of content.matchAll(/docker\s+push\s+['"]?([^\s'"$]+)['"]?/g)) { + const ref = parseImageRef(m[1]) + if (ref) images.push(ref) + } + + // Match: docker buildx ... --push ... -t + for (const m of content.matchAll(/docker\s+buildx\s+[^]*?-t\s+['"]?([^\s'"$]+)['"]?/g)) { + const ref = parseImageRef(m[1]) + if (ref) images.push(ref) + } + + // Match: images: field (GitHub Actions docker/build-push-action) + for (const m of content.matchAll(/images:\s*['"]?([^\s'"#]+)['"]?/g)) { + const ref = parseImageRef(m[1]) + if (ref) images.push(ref) + } + + // Match: tags: field with full image references + for (const m of content.matchAll(/tags:\s*[|>]?\s*\n((?:\s+.+\n?)*)/g)) { + const block = m[1] + for (const line of block.split('\n')) { + const tagMatch = line.match(/^\s*-?\s*['"]?([^\s'"#$]+)['"]?\s*$/) + if (tagMatch) { + const ref = parseImageRef(tagMatch[1]) + if (ref) images.push(ref) + } + } + } + } + + // Deduplicate + const seen = new Set() + return images.filter(img => { + const key = `${img.registry}:${img.owner}/${img.repo}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +// ── README Image Extraction ──────────────────────────────── + +function extractImagesFromReadme (workDir) { + const images = [] + for (const name of ['README.md', 'readme.md', 'README.MD', 'Readme.md']) { + const p = path.join(workDir, name) + if (fs.existsSync(p)) { + const content = fs.readFileSync(p, 'utf-8') + + // Match ghcr.io/owner/repo:tag + for (const m of content.matchAll(/ghcr\.io\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(?::([a-zA-Z0-9_.-]+))?/g)) { + images.push({ registry: 'ghcr', owner: m[1], repo: m[2], tag: m[3] || null }) + } + + // Match docker run/pull commands + for (const m of content.matchAll(/docker\s+(?:run|pull)\s+[^\n]*?(?:docker\.io\/)?([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(?::([a-zA-Z0-9_.-]+))?/g)) { + if (m[1] === 'io') continue + images.push({ registry: 'dockerhub', owner: m[1], repo: m[2], tag: m[3] || null }) + } + + // Match hub.docker.com/r// URLs + for (const m of content.matchAll(/hub\.docker\.com\/r\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)/g)) { + images.push({ registry: 'dockerhub', owner: m[1], repo: m[2], tag: null }) + } + + break + } + } + + // Deduplicate + const seen = new Set() + return images.filter(img => { + const key = `${img.registry}:${img.owner}/${img.repo}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +// ── Docker Hub Search + Verify ───────────────────────────── + +async function searchAndVerifyDockerHub (query, githubOwner, githubRepo) { + try { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 10000) + const resp = await fetch( + `https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(query)}&page_size=5`, + { signal: controller.signal }, + ) + clearTimeout(timer) + + if (!resp.ok) return null + const data = await resp.json() + if (!data.results || data.results.length === 0) return null + + const githubUrlPattern = new RegExp(`github\\.com[/:]${githubOwner}/${githubRepo}`, 'i') + + for (const result of data.results) { + const ns = result.repo_owner || result.repo_name?.split('/')[0] + const repo = result.repo_name?.includes('/') ? result.repo_name.split('/')[1] : result.repo_name + if (!ns || !repo) continue + + // Fetch detail to check full_description for GitHub URL + try { + const detailController = new AbortController() + const detailTimer = setTimeout(() => detailController.abort(), 10000) + const detailResp = await fetch( + `https://hub.docker.com/v2/repositories/${ns}/${repo}/`, + { signal: detailController.signal }, + ) + clearTimeout(detailTimer) + + if (!detailResp.ok) continue + const detail = await detailResp.json() + + const desc = (detail.full_description || '') + ' ' + (detail.description || '') + if (!githubUrlPattern.test(desc)) continue + + // Verified match — check tags for amd64 + const tagResult = await checkDockerHub(ns, repo) + if (tagResult) { + return { ...tagResult, source: 'dockerhub-search' } + } + } catch { + continue + } + } + + return null + } catch { + return null + } +} + +// ── Orchestrator ─────────────────────────────────────────── + +async function detectExistingImage (githubUrl, workDir) { + const parsed = parseGithubUrl(githubUrl) + if (!parsed) { + return { found: false, error: 'Cannot parse GitHub URL' } + } + const { owner, repo } = parsed + + // ── Phase 1: Direct name checks ── + + // 1. Docker Hub / + const dockerhub = await checkDockerHub(owner, repo) + if (dockerhub) return { found: true, ...dockerhub } + + // 2. Docker Hub / (common pattern: GitHub org ≠ Docker Hub namespace) + if (repo !== owner) { + const dockerhubFallback = await checkDockerHub(repo, repo) + if (dockerhubFallback) return { found: true, ...dockerhubFallback } + } + + // 3. GHCR / + const ghcr = await checkGhcr(owner, repo) + if (ghcr) return { found: true, ...ghcr } + + // ── Phase 2: Project file evidence ── + + if (workDir) { + // 4. docker-compose.yml image: scan + const composeImages = extractImagesFromCompose(workDir) + for (const img of composeImages) { + if (img.registry === 'ghcr') { + const result = await checkGhcr(img.owner, img.repo) + if (result) return { found: true, ...result, source: 'compose' } + } else { + const result = await checkDockerHub(img.owner, img.repo) + if (result) return { found: true, ...result, source: 'compose' } + } + } + + // 5. CI workflow docker push scan + const workflowImages = extractImagesFromWorkflows(workDir) + for (const img of workflowImages) { + if (img.registry === 'ghcr') { + const result = await checkGhcr(img.owner, img.repo) + if (result) return { found: true, ...result, source: 'ci-workflow' } + } else { + const result = await checkDockerHub(img.owner, img.repo) + if (result) return { found: true, ...result, source: 'ci-workflow' } + } + } + } + + // ── Phase 3: README scan ── + + if (workDir) { + const readmeImages = extractImagesFromReadme(workDir) + + for (const img of readmeImages) { + if (img.owner === owner && img.repo === repo) continue + + if (img.registry === 'ghcr') { + const result = await checkGhcr(img.owner, img.repo) + if (result) return { found: true, ...result, source: `${result.source}-readme` } + } else { + const result = await checkDockerHub(img.owner, img.repo) + if (result) return { found: true, ...result, source: `${result.source}-readme` } + } + } + } + + // ── Phase 4: Docker Hub search + verify ── + + const searchResult = await searchAndVerifyDockerHub(repo, owner, repo) + if (searchResult) return { found: true, ...searchResult } + + return { found: false } +} + +// ── Git Remote Helper ───────────────────────────────────── + +function getGithubUrlFromGitRemote (dir) { + try { + const remote = execSync('git remote get-url origin', { cwd: dir, encoding: 'utf-8' }).trim() + if (remote.includes('github.com')) return remote + } catch {} + return null +} + +// ── CLI ──────────────────────────────────────────────────── + +const [, , arg1, arg2] = process.argv + +if (!arg1) { + console.error('Usage: node detect-image.mjs [work-dir]') + console.error(' node detect-image.mjs ') + process.exit(1) +} + +// Determine if arg1 is a URL or a local path +let githubUrl, workDir +if (/^https?:\/\//.test(arg1) || arg1.startsWith('git@')) { + githubUrl = arg1 + workDir = arg2 || '.' +} else { + // arg1 is a local path, try to get GitHub URL from git remote + workDir = arg1 + githubUrl = getGithubUrlFromGitRemote(workDir) +} + +if (githubUrl) { + const result = await detectExistingImage(githubUrl, workDir) + console.log(JSON.stringify(result, null, 2)) +} else { + // No GitHub URL available — scan project files and README for image references + const allImages = [ + ...extractImagesFromCompose(workDir), + ...extractImagesFromWorkflows(workDir), + ...extractImagesFromReadme(workDir), + ] + + // Deduplicate across all sources + const seen = new Set() + const unique = allImages.filter(img => { + const key = `${img.registry}:${img.owner}/${img.repo}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + + for (const img of unique) { + let result + if (img.registry === 'ghcr') { + result = await checkGhcr(img.owner, img.repo) + } else { + result = await checkDockerHub(img.owner, img.repo) + } + if (result) { + console.log(JSON.stringify({ found: true, ...result, source: `${result.source}-local` }, null, 2)) + process.exit(0) + } + } + console.log(JSON.stringify({ found: false })) +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/ensure-image-pull-secret.mjs b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/ensure-image-pull-secret.mjs new file mode 100644 index 00000000..c0ae068f --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/ensure-image-pull-secret.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +import { execFileSync } from 'child_process' +import { ensureGhScopesWithPrompt, run } from './gh-auth-utils.mjs' + +function runFile (command, args, opts = {}) { + return execFileSync(command, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim() +} + +function getKubeEnv () { + return { + ...process.env, + KUBECONFIG: process.env.KUBECONFIG || `${process.env.HOME}/.sealos/kubeconfig`, + } +} + +function parseImageRegistry (imageRef) { + const text = String(imageRef || '').trim() + if (!text) return '' + + const withoutDigest = text.split('@', 1)[0] + const withoutTag = withoutDigest.includes(':') && withoutDigest.lastIndexOf(':') > withoutDigest.lastIndexOf('/') + ? withoutDigest.slice(0, withoutDigest.lastIndexOf(':')) + : withoutDigest + const first = withoutTag.split('/', 1)[0] + if (first.includes('.') || first.includes(':') || first === 'localhost') { + return first + } + return 'docker.io' +} + +async function ensureGhAuth () { + return ensureGhScopesWithPrompt( + ['write:packages'], + 'GHCR image pull secret creation', + ) +} + +function ensureKubectl () { + try { + run('kubectl version --client=true --output=yaml', { env: getKubeEnv() }) + } catch { + throw new Error('kubectl is required to create image pull secrets') + } +} + +function createOrUpdateDockerRegistrySecret ({ namespace, secretName, registry, username, password, email }) { + const escapedPassword = password.replace(/"/g, '\\"') + const escapedUsername = username.replace(/"/g, '\\"') + const escapedEmail = email.replace(/"/g, '\\"') + const script = [ + `KUBECONFIG=\${KUBECONFIG:-$HOME/.sealos/kubeconfig}`, + 'kubectl --insecure-skip-tls-verify create secret docker-registry ' + + `${secretName} -n ${namespace} ` + + `--docker-server=${registry} ` + + `--docker-username="${escapedUsername}" ` + + `--docker-password="${escapedPassword}" ` + + `--docker-email="${escapedEmail}" ` + + '--dry-run=client -o yaml | ' + + 'kubectl --insecure-skip-tls-verify apply -f -', + ].join(' && ') + + runFile('sh', ['-c', script], { env: getKubeEnv() }) +} + +function getDeploymentImagePullSecretNames ({ namespace, deploymentName }) { + const output = runFile( + 'kubectl', + ['--insecure-skip-tls-verify', 'get', 'deployment', deploymentName, '-n', namespace, '-o', 'json'], + { env: getKubeEnv() }, + ) + const deployment = JSON.parse(output) + return (deployment.spec?.template?.spec?.imagePullSecrets || []) + .map(secret => secret?.name) + .filter(Boolean) +} + +function ensureDeploymentImagePullSecret ({ namespace, deploymentName, secretName }) { + if (!deploymentName) { + return { action: 'skipped', reason: 'no deployment specified' } + } + + const existingSecretNames = getDeploymentImagePullSecretNames({ namespace, deploymentName }) + if (existingSecretNames.includes(secretName)) { + return { action: 'already_present', image_pull_secrets: existingSecretNames } + } + + const mergedSecrets = [...existingSecretNames, secretName].map(name => ({ name })) + const patch = JSON.stringify({ + spec: { + template: { + spec: { + imagePullSecrets: mergedSecrets, + }, + }, + }, + }) + + runFile( + 'kubectl', + ['--insecure-skip-tls-verify', 'patch', 'deployment', deploymentName, '-n', namespace, '--type', 'merge', '-p', patch], + { env: getKubeEnv() }, + ) + + return { + action: 'patched', + image_pull_secrets: mergedSecrets.map(secret => secret.name), + } +} + +function parseArgs (argv) { + const args = argv.slice(2) + if (args.length < 3) { + throw new Error('Usage: node ensure-image-pull-secret.mjs [deployment-name]') + } + + return { + namespace: args[0], + secretName: args[1], + imageRef: args[2], + deploymentName: args[3] || null, + } +} + +try { + const { namespace, secretName, imageRef, deploymentName } = parseArgs(process.argv) + const registry = parseImageRegistry(imageRef) + + if (registry !== 'ghcr.io') { + console.log(JSON.stringify({ + success: true, + action: 'skipped', + reason: `registry ${registry || 'unknown'} does not use gh CLI pull-secret automation`, + }, null, 2)) + process.exit(0) + } + + const authCheck = await ensureGhAuth() + if (!authCheck.ok) { + console.log(JSON.stringify({ + success: false, + ...authCheck, + }, null, 2)) + process.exit(1) + } + ensureKubectl() + + const username = run('gh api user -q .login') + const password = run('gh auth token') + createOrUpdateDockerRegistrySecret({ + namespace, + secretName, + registry, + username, + password, + email: 'none@example.com', + }) + const deployment = ensureDeploymentImagePullSecret({ + namespace, + deploymentName, + secretName, + }) + + console.log(JSON.stringify({ + success: true, + action: 'created_or_updated', + namespace, + secret_name: secretName, + registry, + username, + deployment_name: deploymentName, + deployment, + }, null, 2)) +} catch (error) { + const structured = error && typeof error === 'object' && 'error' in error + console.log(JSON.stringify({ + success: false, + ...(structured ? error : { error: error.message }), + }, null, 2)) + process.exit(1) +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/gh-auth-utils.mjs b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/gh-auth-utils.mjs new file mode 100644 index 00000000..d162a3fc --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/gh-auth-utils.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { execSync } from 'child_process' +import { dirname, join } from 'path' +import { createInterface } from 'readline/promises' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const GH_REFRESH_SCRIPT = join(__dirname, 'gh-refresh-scopes.mjs') +const IMPLIED_SCOPES = { + 'write:packages': ['read:packages'], +} + +export function run (cmd, opts = {}) { + return execSync(cmd, { encoding: 'utf-8', stdio: 'pipe', ...opts }).trim() +} + +export function hasGhCli () { + try { + run('gh --version') + return true + } catch { + return false + } +} + +export function getGhAuthStatusOutput () { + try { + return { + authenticated: true, + output: run('gh auth status 2>&1'), + } + } catch (error) { + const output = `${error.stdout || ''}${error.stderr || ''}`.trim() + return { + authenticated: false, + output, + } + } +} + +export function parseGhScopes (statusOutput) { + const text = String(statusOutput || '') + const scopes = Array.from(text.matchAll(/'([^']+)'/g), (match) => match[1]) + return Array.from(new Set(scopes)) +} + +function expandImpliedScopes (scopes) { + const expanded = new Set(scopes) + for (const scope of Array.from(expanded)) { + const implied = IMPLIED_SCOPES[scope] || [] + for (const item of implied) expanded.add(item) + } + return Array.from(expanded) +} + +export function getMissingScopes (presentScopes, requiredScopes) { + const have = new Set(expandImpliedScopes(presentScopes)) + return Array.from(new Set(requiredScopes)).filter(scope => !have.has(scope)) +} + +export function buildScopeRefreshCommand (requiredScopes) { + const scopeList = Array.from(new Set(requiredScopes)).join(',') + return `node ${JSON.stringify(GH_REFRESH_SCRIPT)} ${scopeList}` +} + +function buildScopeLoginCommand (requiredScopes) { + const scopeList = Array.from(new Set(requiredScopes)).join(',') + return `gh auth login --hostname github.com --git-protocol https --web --scopes ${scopeList}` +} + +function buildScopeRefreshAction (requiredScopes, purpose, presentScopes) { + const normalizedScopes = Array.from(new Set(requiredScopes)) + const missingScopes = getMissingScopes(presentScopes, normalizedScopes) + return { + ok: false, + action: 'gh_scope_refresh_required', + retryable: true, + tty_required: true, + purpose, + required_scopes: normalizedScopes, + missing_scopes: missingScopes, + suggested_command: buildScopeRefreshCommand(normalizedScopes), + error: `gh CLI is authenticated but missing required GitHub scopes for ${purpose}: ${missingScopes.join(', ')}`, + } +} + +export function ensureGhScopes (requiredScopes, purpose) { + if (!hasGhCli()) { + return { + ok: false, + error: 'gh CLI is not installed. Install it with: brew install gh && gh auth login', + } + } + + const status = getGhAuthStatusOutput() + if (!status.authenticated) { + return { + ok: false, + error: 'gh CLI not authenticated. Run: gh auth login', + } + } + + const currentScopes = parseGhScopes(status.output) + const missingScopes = getMissingScopes(currentScopes, requiredScopes) + if (missingScopes.length === 0) { + return { ok: true, scopes: currentScopes } + } + + return buildScopeRefreshAction(requiredScopes, purpose, currentScopes) +} + +export async function ensureGhScopesWithPrompt (requiredScopes, purpose, promptText = 'Missing GitHub Packages permission for GHCR. Refresh now? (y/n) ') { + const scopeCheck = ensureGhScopes(requiredScopes, purpose) + if (scopeCheck.ok || scopeCheck.action !== 'gh_scope_refresh_required') { + return scopeCheck + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return scopeCheck + } + + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }) + + let answer = '' + try { + answer = await rl.question(promptText) + } finally { + rl.close() + } + + if (!/^(y|yes)$/i.test(String(answer).trim())) { + return { + ...scopeCheck, + user_declined: true, + retryable: false, + error: `${scopeCheck.error}. User declined GitHub scope refresh.`, + } + } + + const scopeList = Array.from(new Set(requiredScopes)).join(',') + try { + execSync(`gh auth refresh -h github.com -s ${scopeList}`, { stdio: 'inherit' }) + } catch { + return { + ...scopeCheck, + error: `gh auth refresh was not completed. ${scopeCheck.error}`, + } + } + + const refreshed = ensureGhScopes(requiredScopes, purpose) + if (refreshed.ok) { + return { + ...refreshed, + refreshed: true, + } + } + console.error(`gh auth refresh completed but scopes are still missing. Trying a full GitHub CLI re-auth for ${purpose} in this same session...`) + + try { + execSync(buildScopeLoginCommand(requiredScopes), { stdio: 'inherit' }) + } catch { + return { + ...refreshed, + error: `gh auth refresh completed, but required scopes are still missing for ${purpose}. Follow-up gh auth login was not completed.`, + } + } + + const relogged = ensureGhScopes(requiredScopes, purpose) + if (relogged.ok) { + return { + ...relogged, + refreshed: true, + relogged: true, + } + } + + return { + ...relogged, + error: `GitHub CLI re-auth completed, but required scopes are still missing for ${purpose}: ${relogged.missing_scopes?.join(', ') || 'unknown'}`, + } +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/gh-refresh-scopes.mjs b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/gh-refresh-scopes.mjs new file mode 100644 index 00000000..7e3e168c --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/gh-refresh-scopes.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import { execSync } from 'child_process' +import { ensureGhScopes, getMissingScopes, parseGhScopes, getGhAuthStatusOutput, hasGhCli } from './gh-auth-utils.mjs' + +function fail(message, extra = {}, code = 1) { + console.error(JSON.stringify({ success: false, error: message, ...extra }, null, 2)) + process.exit(code) +} + +function parseArgs(argv) { + const args = argv.slice(2) + .flatMap(arg => arg.split(',')) + .map(arg => arg.trim()) + .filter(Boolean) + + if (args.length === 0) { + fail('Usage: node gh-refresh-scopes.mjs ') + } + + return Array.from(new Set(args)) +} + +const requiredScopes = parseArgs(process.argv) + +if (!hasGhCli()) { + fail('gh CLI is not installed. Install it with: brew install gh && gh auth login') +} + +const status = getGhAuthStatusOutput() +if (!status.authenticated) { + fail('gh CLI not authenticated. Run: gh auth login') +} + +const initialCheck = ensureGhScopes(requiredScopes, 'GHCR flow') +if (initialCheck.ok) { + console.log(JSON.stringify({ + success: true, + skipped: true, + reason: 'required scopes already satisfied', + scopes: initialCheck.scopes, + required_scopes: requiredScopes, + }, null, 2)) + process.exit(0) +} + +if (!process.stdin.isTTY || !process.stdout.isTTY) { + const currentScopes = parseGhScopes(status.output) + fail('TTY is required to refresh GitHub scopes in-place', { + action: 'gh_scope_refresh_required', + retryable: true, + tty_required: true, + required_scopes: requiredScopes, + missing_scopes: getMissingScopes(currentScopes, requiredScopes), + }) +} + +const scopeList = requiredScopes.join(',') +console.error(`Refreshing GitHub scopes: ${scopeList}`) + +try { + execSync(`gh auth refresh -h github.com -s ${scopeList}`, { stdio: 'inherit' }) +} catch { + fail('gh auth refresh was not completed', { + action: 'gh_scope_refresh_required', + retryable: true, + tty_required: true, + required_scopes: requiredScopes, + }) +} + +const scopeCheck = ensureGhScopes(requiredScopes, 'GHCR flow') +if (scopeCheck.ok) { + console.log(JSON.stringify({ + success: true, + refreshed: true, + scopes: scopeCheck.scopes, + required_scopes: requiredScopes, + }, null, 2)) + process.exit(0) +} + +console.error('gh auth refresh completed but scopes are still missing. Trying a full GitHub CLI re-auth in this same session...') + +try { + execSync(`gh auth login --hostname github.com --git-protocol https --web --scopes ${scopeList}`, { stdio: 'inherit' }) +} catch { + fail('gh auth refresh completed, but follow-up gh auth login was not completed', scopeCheck) +} + +const relogged = ensureGhScopes(requiredScopes, 'GHCR flow') +if (!relogged.ok) { + fail('GitHub CLI re-auth completed, but required scopes are still missing', relogged) +} + +console.log(JSON.stringify({ + success: true, + refreshed: true, + relogged: true, + scopes: relogged.scopes, + required_scopes: requiredScopes, +}, null, 2)) diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/score-model.mjs b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/score-model.mjs new file mode 100644 index 00000000..b4f08926 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/score-model.mjs @@ -0,0 +1,583 @@ +#!/usr/bin/env node + +/** + * Cloud-Native Readiness Scoring Model + * + * A deterministic scoring algorithm trained on 164 Sealos production templates. + * All 164 projects are confirmed containerizable (ground truth = positive). + * + * This model is designed to be used in two ways: + * 1. Standalone: node scripts/score-model.js + * 2. Imported: import { scoreProject } from './scripts/score-model.js' + * + * The model analyzes the LOCAL filesystem (cloned repo), NOT GitHub API. + * This makes it fast, offline-capable, and accurate. + */ + +import fs from 'fs'; +import path from 'path'; + +// ─── Language Priority ─────────────────────────────────────── + +const LANGUAGE_PRIORITY = ['go', 'rust', 'java', 'node', 'python', 'php', 'ruby', 'dotnet']; + +function pickPrimaryLanguage(langSignals, fwSignals) { + const detected = Object.entries(langSignals).filter(([, v]) => v).map(([k]) => k); + if (detected.length <= 1) return detected[0] || null; + + // Prefer languages with a detected web framework + const withFramework = detected.filter(lang => { + if (lang === 'node') return fwSignals.nextjs || fwSignals.nuxt || fwSignals.express || fwSignals.hono || fwSignals.fastify || fwSignals.nestjs; + if (lang === 'python') return fwSignals.fastapi || fwSignals.django || fwSignals.flask; + if (lang === 'go') return fwSignals.gin || fwSignals.echo || fwSignals.fiber; + if (lang === 'java') return fwSignals.spring; + return false; + }); + + if (withFramework.length === 1) return withFramework[0]; + + // Multiple with frameworks or none → sort by priority (compiled > interpreted) + const pool = withFramework.length > 0 ? withFramework : detected; + return pool.sort((a, b) => LANGUAGE_PRIORITY.indexOf(a) - LANGUAGE_PRIORITY.indexOf(b))[0]; +} + +// ─── Signal Detection ─────────────────────────────────────── + +function detectSignals(repoDir) { + const has = (f) => fs.existsSync(path.join(repoDir, f)); + const hasAny = (...files) => files.some(has); + const readJson = (f) => { + try { + return JSON.parse(fs.readFileSync(path.join(repoDir, f), 'utf-8')); + } catch { + return null; + } + }; + const grepFile = (f, pattern) => { + try { + const content = fs.readFileSync(path.join(repoDir, f), 'utf-8'); + return pattern.test(content); + } catch { + return false; + } + }; + const grepDir = (dir, pattern, exts = ['.ts', '.js', '.py', '.go', '.java', '.rs', '.php', '.rb']) => { + try { + return grepRecursive(path.join(repoDir, dir), pattern, exts, 0); + } catch { + return false; + } + }; + + // ── Language Detection (check root + up to 2 levels deep for monorepos) ── + const lang = {}; + const hasDeep = (pattern) => findFiles(repoDir, pattern, 2).length > 0; + lang.node = has('package.json') || hasDeep(/^package\.json$/); + lang.go = has('go.mod') || hasDeep(/^go\.mod$/); + lang.python = hasAny('requirements.txt', 'pyproject.toml', 'setup.py', 'Pipfile') || hasDeep(/^(requirements\.txt|pyproject\.toml)$/); + lang.java = hasAny('pom.xml', 'build.gradle', 'build.gradle.kts') || hasDeep(/^(pom\.xml|build\.gradle)$/); + lang.rust = has('Cargo.toml') || hasDeep(/^Cargo\.toml$/); + lang.php = has('composer.json') || hasDeep(/^composer\.json$/); + lang.ruby = has('Gemfile') || hasDeep(/^Gemfile$/); + lang.dotnet = findFiles(repoDir, /\.(csproj|sln)$/, 2).length > 0; + + // ── Framework Detection (scans root + all sub package.json for monorepos) ── + const fw = {}; + let _allNodeDeps = {}; + if (lang.node) { + // Collect ALL deps across all package.json files (monorepo support) + const allPkgFiles = [ + path.join(repoDir, 'package.json'), + ...findFiles(repoDir, /^package\.json$/, 3), + ]; + const allNodeDeps = {}; + for (const pkgFile of allPkgFiles) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf-8')); + Object.assign(allNodeDeps, pkg.dependencies || {}, pkg.devDependencies || {}); + } catch { /* skip */ } + } + + fw.nextjs = 'next' in allNodeDeps || hasAny('next.config.js', 'next.config.ts', 'next.config.mjs') || findFiles(repoDir, /^next\.config\.(js|ts|mjs)$/, 2).length > 0; + fw.nuxt = 'nuxt' in allNodeDeps || has('nuxt.config.ts'); + fw.express = 'express' in allNodeDeps; + fw.hono = 'hono' in allNodeDeps; + fw.fastify = 'fastify' in allNodeDeps; + fw.nestjs = '@nestjs/core' in allNodeDeps; + fw.astro = 'astro' in allNodeDeps; + fw.vite = 'vite' in allNodeDeps; + fw.react = 'react' in allNodeDeps; + fw.vue = 'vue' in allNodeDeps; + + // Override allDeps for state detection later + _allNodeDeps = allNodeDeps; + } + if (lang.python) { + fw.fastapi = grepFile('requirements.txt', /fastapi/i) || grepFile('pyproject.toml', /fastapi/i); + fw.django = grepFile('requirements.txt', /django/i) || has('manage.py'); + fw.flask = grepFile('requirements.txt', /flask/i) || grepFile('pyproject.toml', /flask/i); + } + if (lang.go) { + fw.gin = grepFile('go.mod', /gin-gonic/); + fw.echo = grepFile('go.mod', /labstack\/echo/); + fw.fiber = grepFile('go.mod', /gofiber\/fiber/); + } + if (lang.java) { + fw.spring = grepFile('pom.xml', /spring-boot/) || grepFile('build.gradle', /spring-boot/); + } + + // ── HTTP Server Detection (most critical signal) ── + const http = {}; + http.has_port_listen = false; + http.has_http_handler = false; + + if (lang.node) { + const pkg = readJson('package.json'); + const scripts = pkg?.scripts || {}; + http.has_start_script = 'start' in scripts || 'serve' in scripts; + http.has_port_listen = http.has_start_script || fw.nextjs || fw.nuxt || fw.express || fw.hono || fw.fastify || fw.nestjs; + http.has_http_handler = fw.express || fw.hono || fw.fastify || fw.nestjs || fw.nextjs || fw.nuxt; + } + if (lang.go) { + const hasGoWebFw = fw.gin || fw.echo || fw.fiber || + grepFile('go.mod', /go-chi\/chi|gorilla\/mux/); + const hasGoHttpCode = grepDir('', /http\.ListenAndServe|ListenAndServeTLS/, ['.go']); + http.has_http_handler = !!(hasGoWebFw || hasGoHttpCode); + http.has_port_listen = http.has_http_handler; + } + if (lang.python) { + http.has_port_listen = fw.fastapi || fw.django || fw.flask; + http.has_http_handler = fw.fastapi || fw.django || fw.flask; + } + if (lang.java) { + http.has_port_listen = fw.spring; + http.has_http_handler = fw.spring; + } + if (lang.rust) { + http.has_port_listen = grepFile('Cargo.toml', /actix-web|axum|rocket|warp|hyper/); + http.has_http_handler = http.has_port_listen; + } + if (lang.php) { + http.has_port_listen = true; // PHP always served via web server + http.has_http_handler = true; + } + if (lang.ruby) { + http.has_port_listen = has('config.ru') || grepFile('Gemfile', /rails|sinatra|puma/); + http.has_http_handler = http.has_port_listen; + } + + // ── State Externalization ── + const state = {}; + const allDeps = lang.node ? (_allNodeDeps || readJson('package.json')?.dependencies || {}) : {}; + + state.uses_postgres = + 'pg' in allDeps || + 'postgres' in allDeps || + '@prisma/client' in allDeps || + 'drizzle-orm' in allDeps || + 'typeorm' in allDeps || + 'sequelize' in allDeps || + hasAny('prisma/schema.prisma'); + state.uses_mysql = 'mysql2' in allDeps || 'mysql' in allDeps; + state.uses_mongodb = 'mongoose' in allDeps || 'mongodb' in allDeps; + state.uses_redis = 'redis' in allDeps || 'ioredis' in allDeps || '@upstash/redis' in allDeps; + state.uses_sqlite = 'better-sqlite3' in allDeps || 'sqlite3' in allDeps; + state.uses_s3 = '@aws-sdk/client-s3' in allDeps || 'minio' in allDeps; + state.uses_external_db = state.uses_postgres || state.uses_mysql || state.uses_mongodb; + + if (lang.python) { + state.uses_postgres = state.uses_postgres || grepFile('requirements.txt', /psycopg|asyncpg|sqlalchemy/i); + state.uses_mysql = state.uses_mysql || grepFile('requirements.txt', /mysqlclient|pymysql/i); + state.uses_mongodb = state.uses_mongodb || grepFile('requirements.txt', /pymongo|motor/i); + state.uses_redis = state.uses_redis || grepFile('requirements.txt', /redis/i); + state.uses_external_db = state.uses_postgres || state.uses_mysql || state.uses_mongodb; + } + + if (lang.go) { + state.uses_postgres = grepFile('go.mod', /lib\/pq|pgx|gorm\.io/); + state.uses_mysql = grepFile('go.mod', /go-sql-driver\/mysql/); + state.uses_mongodb = grepFile('go.mod', /mongo-driver/); + state.uses_redis = grepFile('go.mod', /go-redis|redigo/); + state.uses_external_db = state.uses_postgres || state.uses_mysql || state.uses_mongodb; + } + + // ── Config Externalization ── + const config = {}; + config.has_env_example = hasAny('.env.example', '.env.sample', '.env.template', '.dev.vars.example') || + findFiles(repoDir, /^\.(env\.example|env\.sample|env\.template|dev\.vars\.example)$/, 2).length > 0; + config.has_env_file = hasAny('.env', '.env.local', '.env.development') || + findFiles(repoDir, /^\.env(\.local|\.development)?$/, 2).length > 0; + config.has_env_validation = lang.node && ( + '@t3-oss/env-nextjs' in allDeps || + 'envalid' in allDeps || + 'env-var' in allDeps + ); + + // ── Docker Artifacts ── + const docker = {}; + const _dockerfilePaths = findFiles(repoDir, /^(Dockerfile|dockerfile)(\.[\w.-]+)?$/, 3); + const _composePaths = findFiles(repoDir, /^(docker-compose|compose)\.(yml|yaml)$/, 3); + docker.has_dockerfile = _dockerfilePaths.length > 0; + docker.has_compose = _composePaths.length > 0; + docker._dockerfile_paths = _dockerfilePaths.map(f => path.relative(repoDir, f)); + docker.has_dockerignore = has('.dockerignore'); + docker.has_k8s = hasAny('k8s', 'kubernetes', 'helm', 'charts', 'Chart.yaml', 'kustomization.yaml'); + docker.has_any = docker.has_dockerfile || docker.has_compose; + + // ── Monorepo ── + const mono = {}; + mono.is_monorepo = hasAny('pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'); + mono.has_apps_dir = has('apps') || has('services'); + + // ── Lifecycle ── + const lifecycle = {}; + if (lang.node) { + const pkg = readJson('package.json'); + const scripts = pkg?.scripts || {}; + lifecycle.has_start = 'start' in scripts; + lifecycle.has_build = 'build' in scripts; + lifecycle.has_dev = 'dev' in scripts; + } + lifecycle.has_health_check = _dockerfilePaths.some(f => { + try { return /HEALTHCHECK/.test(fs.readFileSync(f, 'utf-8')); } catch { return false; } + }); + if (lang.java) { + lifecycle.has_build = hasAny('pom.xml', 'gradlew', 'mvnw', 'build.gradle', 'build.gradle.kts'); + lifecycle.has_start = !!fw.spring; + if (!lifecycle.has_health_check) { + lifecycle.has_health_check = + grepFile('pom.xml', /spring-boot-starter-actuator/) || + grepFile('build.gradle', /spring-boot-starter-actuator/) || + grepFile('build.gradle.kts', /spring-boot-starter-actuator/); + } + } + + // ── Package Manager Detection ── + const pm = {}; + if (lang.node) { + if (has('pnpm-lock.yaml')) pm.name = 'pnpm'; + else if (has('yarn.lock')) pm.name = 'yarn'; + else if (has('bun.lockb') || has('bun.lock')) pm.name = 'bun'; + else pm.name = 'npm'; + } else if (lang.python) { + pm.name = has('Pipfile') ? 'pipenv' : 'pip'; + } else if (lang.go) { + pm.name = 'go'; + } else if (lang.java) { + pm.name = has('gradlew') || has('build.gradle') || has('build.gradle.kts') ? 'gradle' : 'maven'; + } else if (lang.rust) { + pm.name = 'cargo'; + } else if (lang.php) { + pm.name = 'composer'; + } else if (lang.ruby) { + pm.name = 'bundler'; + } + + // ── Port Detection (concrete value) ── + const port = {}; + if (lang.node) { + if (fw.nextjs || fw.nuxt) port.value = 3000; + else if (fw.nestjs) port.value = 3000; + else if (fw.astro) port.value = 4321; + } + if (lang.go && !port.value) port.value = 8080; + if (lang.python && (fw.fastapi || fw.flask) && !port.value) port.value = 8000; + if (lang.python && fw.django && !port.value) port.value = 8000; + if (lang.java && fw.spring && !port.value) port.value = 8080; + if (lang.rust && !port.value) port.value = 8080; + if (lang.php && !port.value) port.value = 80; + if (lang.ruby && !port.value) port.value = 3000; + port.source = port.value ? 'framework-default' : 'unknown'; + + // ── Database Types (concrete list) ── + const databases = []; + if (state.uses_postgres) databases.push('postgres'); + if (state.uses_mysql) databases.push('mysql'); + if (state.uses_mongodb) databases.push('mongodb'); + if (state.uses_redis) databases.push('redis'); + if (state.uses_sqlite) databases.push('sqlite'); + + // ── Runtime Version Detection ── + const runtime_version = {}; + if (lang.node) { + const pkg = readJson('package.json'); + const engines = pkg?.engines?.node; + if (engines) { + const match = engines.match(/(\d+)/); + runtime_version.node = match ? match[1] : '22'; + runtime_version.source = 'engines'; + } else { + const versionFiles = ['.node-version', '.nvmrc']; + for (const f of versionFiles) { + if (has(f)) { + try { + const raw = fs.readFileSync(path.join(repoDir, f), 'utf-8').trim(); + const m = raw.match(/(\d+)/); + if (m) { runtime_version.node = m[1]; runtime_version.source = f; break; } + } catch { /* skip */ } + } + } + if (!runtime_version.node) { + if (has('.tool-versions')) { + try { + const content = fs.readFileSync(path.join(repoDir, '.tool-versions'), 'utf-8'); + const m = content.match(/nodejs?\s+(\d+)/); + if (m) { runtime_version.node = m[1]; runtime_version.source = '.tool-versions'; } + } catch { /* skip */ } + } + } + if (!runtime_version.node) { runtime_version.node = '22'; runtime_version.source = 'default'; } + } + } else if (lang.python) { + if (has('.python-version')) { + try { + const raw = fs.readFileSync(path.join(repoDir, '.python-version'), 'utf-8').trim(); + const m = raw.match(/(\d+\.\d+)/); + if (m) { runtime_version.python = m[1]; runtime_version.source = '.python-version'; } + } catch { /* skip */ } + } + if (!runtime_version.python) { + const pyproject = has('pyproject.toml') ? + fs.readFileSync(path.join(repoDir, 'pyproject.toml'), 'utf-8') : ''; + const m = pyproject.match(/requires-python\s*=\s*"[><=]*(\d+\.\d+)/); + if (m) { runtime_version.python = m[1]; runtime_version.source = 'pyproject.toml'; } + } + if (!runtime_version.python) { runtime_version.python = '3.12'; runtime_version.source = 'default'; } + } else if (lang.go) { + if (has('go.mod')) { + try { + const content = fs.readFileSync(path.join(repoDir, 'go.mod'), 'utf-8'); + const m = content.match(/^go\s+(\d+\.\d+)/m); + if (m) { runtime_version.go = m[1]; runtime_version.source = 'go.mod'; } + } catch { /* skip */ } + } + if (!runtime_version.go) { runtime_version.go = '1.23'; runtime_version.source = 'default'; } + } else if (lang.java) { + runtime_version.java = '21'; runtime_version.source = 'default'; + if (has('pom.xml')) { + try { + const pom = fs.readFileSync(path.join(repoDir, 'pom.xml'), 'utf-8'); + const m = pom.match(/(\d+)(\d+) a + b, 0); + const totalScore = Math.min(12, rawScore + bonus); + + let verdict; + if (totalScore >= 10) verdict = 'Excellent'; + else if (totalScore >= 7) verdict = 'Good'; + else if (totalScore >= 4) verdict = 'Fair'; + else verdict = 'Poor'; + + return { + score: totalScore, + raw_score: rawScore, + bonus, + verdict, + dimensions: scores, + dimension_details: details, + bonus_reasons: bonusReasons, + signals: { + language: Object.entries(s.lang).filter(([, v]) => v).map(([k]) => k), + primary_language: pickPrimaryLanguage(s.lang, s.fw), + framework: Object.entries(s.fw).filter(([, v]) => v).map(([k]) => k), + has_http_server: s.http.has_http_handler, + external_db: s.state.uses_external_db, + has_docker: s.docker.has_any, + is_monorepo: s.mono.is_monorepo, + has_env_example: s.config.has_env_example, + dockerfile_paths: s.docker._dockerfile_paths || [], + package_manager: s.pm.name || null, + port: s.port.value || null, + port_source: s.port.source, + databases: s.databases, + runtime_version: s.runtime_version, + }, + }; +} + +// ─── Helpers ──────────────────────────────────────────────── + +function findFiles(dir, pattern, maxDepth, depth = 0) { + if (depth > maxDepth) return []; + const results = []; + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.git') continue; + if (entry.isFile() && pattern.test(entry.name)) { + results.push(path.join(dir, entry.name)); + } else if (entry.isDirectory() && depth < maxDepth) { + results.push(...findFiles(path.join(dir, entry.name), pattern, maxDepth, depth + 1)); + } + } + } catch { /* ignore permission errors */ } + return results; +} + +function grepRecursive(dir, pattern, exts, depth, maxDepth = 3) { + if (depth > maxDepth) return false; + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'vendor') continue; + const fullPath = path.join(dir, entry.name); + if (entry.isFile() && exts.some((ext) => entry.name.endsWith(ext))) { + try { + const content = fs.readFileSync(fullPath, 'utf-8'); + if (pattern.test(content)) return true; + } catch { /* skip */ } + } else if (entry.isDirectory()) { + if (grepRecursive(fullPath, pattern, exts, depth + 1, maxDepth)) return true; + } + } + } catch { /* ignore */ } + return false; +} + +// ─── CLI ──────────────────────────────────────────────────── + +const repoDir = process.argv[2]; +if (repoDir) { + const absDir = path.resolve(repoDir); + if (!fs.existsSync(absDir)) { + console.error(`Directory not found: ${absDir}`); + process.exit(1); + } + const result = scoreProject(absDir); + console.log(JSON.stringify(result, null, 2)); +} + +export { scoreProject, detectSignals }; diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/sealos-auth.mjs b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/sealos-auth.mjs new file mode 100644 index 00000000..e69ecb9f --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/sealos-auth.mjs @@ -0,0 +1,534 @@ +#!/usr/bin/env node + +/** + * Sealos Cloud Authentication — OAuth2 Device Grant Flow (RFC 8628) + * + * Usage: + * node sealos-auth.mjs check # Check authentication status + current workspace + * node sealos-auth.mjs login [region] # Start OAuth2 device login flow + * node sealos-auth.mjs list # List all workspaces + * node sealos-auth.mjs switch # Switch workspace (by id, uid, or teamName) + * node sealos-auth.mjs info # Show current auth details + * + * Environment variables: + * SEALOS_REGION — Sealos Cloud region URL (default from config.json) + * + * Flow: + * 1. POST /api/auth/oauth2/device → { device_code, user_code, verification_uri_complete } + * 2. User opens verification_uri_complete in browser to authorize + * 3. Script polls /api/auth/oauth2/token until approved + * 4. Receives access_token → exchanges for regional token + kubeconfig + * 5. Saves tokens to ~/.sealos/auth.json, kubeconfig to ~/.sealos/kubeconfig + */ + +import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs' +import { execSync } from 'child_process' +import { homedir, platform } from 'os' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +// ── Paths ──────────────────────────────────────────────── +const __dirname = dirname(fileURLToPath(import.meta.url)) +const SEALOS_DIR = join(homedir(), '.sealos') +const KC_PATH = join(SEALOS_DIR, 'kubeconfig') +const AUTH_PATH = join(SEALOS_DIR, 'auth.json') + +// ── Skill constants (from config.json) ─────────────────── +const CONFIG_PATH = join(__dirname, '..', 'config.json') +const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) +const CLIENT_ID = config.client_id +const DEFAULT_REGION = config.default_region + +// ── Check ────────────────────────────────────────────── + +function check () { + if (!existsSync(KC_PATH)) { + return { authenticated: false } + } + + try { + const kc = readFileSync(KC_PATH, 'utf-8') + if (kc.includes('server:') && (kc.includes('token:') || kc.includes('client-certificate'))) { + const auth = existsSync(AUTH_PATH) ? JSON.parse(readFileSync(AUTH_PATH, 'utf-8')) : {} + return { + authenticated: true, + kubeconfig_path: KC_PATH, + region: auth.region || 'unknown', + workspace: auth.current_workspace?.id || 'unknown' + } + } + } catch { } + + return { authenticated: false } +} + +// ── Device Grant Flow ────────────────────────────────── + +/** + * Step 1: Request device authorization + * POST /api/auth/oauth2/device + * Body: { client_id } + * Response: { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval } + */ +async function requestDeviceAuthorization (region) { + const res = await fetch(`${region}/api/auth/oauth2/device`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code' + }) + }) + + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`Device authorization request failed (${res.status}): ${body || res.statusText}`) + } + + return res.json() +} + +/** + * Step 2: Poll for token + * POST /api/auth/oauth2/token + * Body: { client_id, grant_type, device_code } + * + * Possible responses: + * - 200: { access_token, token_type, ... } → success + * - 400: { error: "authorization_pending" } → keep polling + * - 400: { error: "slow_down" } → increase interval by 5s + * - 400: { error: "access_denied" } → user denied + * - 400: { error: "expired_token" } → device code expired + */ +async function pollForToken (region, deviceCode, interval, expiresIn) { + // Hard cap at 10 minutes regardless of server's expires_in + const maxWait = Math.min(expiresIn, 600) * 1000 + const deadline = Date.now() + maxWait + let pollInterval = interval * 1000 + let lastLoggedMinute = -1 + + while (Date.now() < deadline) { + await sleep(pollInterval) + + // Log remaining time every minute + const remaining = Math.ceil((deadline - Date.now()) / 60000) + if (remaining !== lastLoggedMinute && remaining > 0) { + lastLoggedMinute = remaining + process.stderr.write(` Waiting for authorization... (${remaining} min remaining)\n`) + } + + const res = await fetch(`${region}/api/auth/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode + }) + }) + + if (res.ok) { + // Success — got the token + return res.json() + } + + const body = await res.json().catch(() => ({})) + + switch (body.error) { + case 'authorization_pending': + // User hasn't authorized yet, keep polling + break + + case 'slow_down': + // Increase polling interval by 5 seconds (RFC 8628 §3.5) + pollInterval += 5000 + break + + case 'access_denied': + throw new Error('Authorization denied by user') + + case 'expired_token': + throw new Error('Device code expired. Please run login again.') + + default: + throw new Error(`Token request failed: ${body.error || res.statusText}`) + } + } + + throw new Error('Authorization timed out (10 minutes). Please run login again.') +} + +/** + * Step 3: Exchange global access_token for regional token + kubeconfig + */ +async function getRegionToken (region, globalToken) { + const res = await fetch(`${region}/api/auth/regionToken`, { + method: 'POST', + headers: { + Authorization: globalToken, + 'Content-Type': 'application/json' + } + }) + + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`Region token exchange failed (${res.status}): ${body || res.statusText}`) + } + + return res.json() +} + +/** + * List all workspaces (namespaces) for the authenticated user + */ +async function listWorkspaces (region, regionalToken) { + const res = await fetch(`${region}/api/auth/namespace/list`, { + headers: { Authorization: regionalToken } + }) + + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`List workspaces failed (${res.status}): ${body || res.statusText}`) + } + + return res.json() +} + +/** + * Switch to a different workspace (namespace) + */ +async function switchWorkspace (region, regionalToken, nsUid) { + const res = await fetch(`${region}/api/auth/namespace/switch`, { + method: 'POST', + headers: { + Authorization: regionalToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ns_uid: nsUid }) + }) + + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`Switch workspace failed (${res.status}): ${body || res.statusText}`) + } + + return res.json() +} + +/** + * Get kubeconfig for the current workspace + */ +async function getKubeconfig (region, regionalToken) { + const res = await fetch(`${region}/api/auth/getKubeconfig`, { + headers: { Authorization: regionalToken } + }) + + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`Get kubeconfig failed (${res.status}): ${body || res.statusText}`) + } + + return res.json() +} + +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +// ── Login (Device Grant Flow) ────────────────────────── + +async function login (region = DEFAULT_REGION) { + region = region.replace(/\/+$/, '') + + // Step 1: Request device authorization + const deviceAuth = await requestDeviceAuthorization(region) + + const { + device_code: deviceCode, + user_code: userCode, + verification_uri: verificationUri, + verification_uri_complete: verificationUriComplete, + expires_in: expiresIn, + interval = 5 + } = deviceAuth + + // Output device authorization info for the AI tool / user to display + const authPrompt = { + action: 'user_authorization_required', + user_code: userCode, + verification_uri: verificationUri, + verification_uri_complete: verificationUriComplete, + expires_in: expiresIn, + message: `Please open the following URL in your browser to authorize:\n\n ${verificationUriComplete || verificationUri}\n\nAuthorization code: ${userCode}\nExpires in: ${Math.floor(expiresIn / 60)} minutes` + } + + // Print the authorization prompt to stderr so it's visible to the user + // while stdout is reserved for JSON output + process.stderr.write('\n' + authPrompt.message + '\n\nWaiting for authorization...\n') + + // Auto-open browser + const url = verificationUriComplete || verificationUri + try { + const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open' + execSync(`${cmd} "${url}"`, { stdio: 'ignore' }) + process.stderr.write('Browser opened automatically.\n') + } catch { + process.stderr.write('Could not open browser automatically. Please open the URL manually.\n') + } + + // Step 2: Poll for token + const tokenResponse = await pollForToken(region, deviceCode, interval, expiresIn) + const accessToken = tokenResponse.access_token + + if (!accessToken) { + throw new Error('Token response missing access_token') + } + + process.stderr.write('Authorization received. Exchanging for regional token...\n') + + // Step 3: Exchange global token for regional token + kubeconfig + const regionData = await getRegionToken(region, accessToken) + const regionalToken = regionData.data?.token + const kubeconfig = regionData.data?.kubeconfig + + if (!regionalToken) { + throw new Error('Region token response missing data.token field') + } + if (!kubeconfig) { + throw new Error('Region token response missing data.kubeconfig field') + } + + // Determine current workspace from namespace list + let currentWorkspace = null + try { + const nsData = await listWorkspaces(region, regionalToken) + const namespaces = nsData.data?.namespaces || nsData.data || [] + if (Array.isArray(namespaces) && namespaces.length > 0) { + // The first namespace or the one matching the default is the current workspace + currentWorkspace = namespaces.find(ns => ns.nstype === 'private') || namespaces[0] + } + } catch { + // Non-fatal: workspace info is optional during login + } + + // Save kubeconfig to ~/.sealos/kubeconfig + mkdirSync(SEALOS_DIR, { recursive: true }) + writeFileSync(KC_PATH, kubeconfig, { mode: 0o600 }) + + // Save auth info with tokens + const authData = { + region, + access_token: accessToken, + regional_token: regionalToken, + authenticated_at: new Date().toISOString(), + auth_method: 'oauth2_device_grant' + } + if (currentWorkspace) { + authData.current_workspace = { + uid: currentWorkspace.uid, + id: currentWorkspace.id, + teamName: currentWorkspace.teamName + } + } + writeFileSync(AUTH_PATH, JSON.stringify(authData, null, 2), { mode: 0o600 }) + + process.stderr.write('Authentication successful!\n') + + return { kubeconfig_path: KC_PATH, region, workspace: currentWorkspace?.id || 'default' } +} + +// ── Info ─────────────────────────────────────────────── + +function info () { + const status = check() + if (!status.authenticated) { + return { authenticated: false, message: 'Not authenticated. Run: node sealos-auth.mjs login' } + } + + const auth = existsSync(AUTH_PATH) ? JSON.parse(readFileSync(AUTH_PATH, 'utf-8')) : {} + return { + authenticated: true, + kubeconfig_path: KC_PATH, + region: auth.region || 'unknown', + auth_method: auth.auth_method || 'unknown', + authenticated_at: auth.authenticated_at || 'unknown', + current_workspace: auth.current_workspace || null + } +} + +// ── List Workspaces ───────────────────────────────────── + +async function list () { + const auth = loadAuth() + if (!auth.regional_token) { + throw new Error('No regional_token found. Please run: node sealos-auth.mjs login') + } + + const nsData = await listWorkspaces(auth.region, auth.regional_token) + const namespaces = nsData.data?.namespaces || nsData.data || [] + + return { + current: auth.current_workspace?.id || null, + workspaces: Array.isArray(namespaces) + ? namespaces.map(ns => ({ + uid: ns.uid, + id: ns.id, + teamName: ns.teamName, + role: ns.role, + nstype: ns.nstype + })) + : [] + } +} + +// ── Switch Workspace ──────────────────────────────────── + +async function switchWs (target) { + if (!target) { + throw new Error('Usage: node sealos-auth.mjs switch ') + } + + const auth = loadAuth() + if (!auth.regional_token) { + throw new Error('No regional_token found. Please run: node sealos-auth.mjs login') + } + + // Find matching workspace + const nsData = await listWorkspaces(auth.region, auth.regional_token) + const namespaces = nsData.data?.namespaces || nsData.data || [] + + if (!Array.isArray(namespaces) || namespaces.length === 0) { + throw new Error('No workspaces found') + } + + const targetLower = target.toLowerCase() + const match = namespaces.find(ns => + ns.id === target || + ns.uid === target || + ns.id?.toLowerCase().includes(targetLower) || + ns.teamName?.toLowerCase().includes(targetLower) + ) + + if (!match) { + const available = namespaces.map(ns => ` ${ns.id} (${ns.teamName})`).join('\n') + throw new Error(`No workspace matching "${target}". Available:\n${available}`) + } + + process.stderr.write(`Switching to workspace: ${match.id} (${match.teamName})...\n`) + + // Switch namespace to get new regional token + const switchData = await switchWorkspace(auth.region, auth.regional_token, match.uid) + const newToken = switchData.data?.token + if (!newToken) { + throw new Error('Switch response missing data.token') + } + + // Get kubeconfig for the new workspace + const kcData = await getKubeconfig(auth.region, newToken) + const kubeconfig = kcData.data?.kubeconfig + if (!kubeconfig) { + throw new Error('Kubeconfig response missing data.kubeconfig') + } + + // Update auth.json + auth.regional_token = newToken + auth.current_workspace = { + uid: match.uid, + id: match.id, + teamName: match.teamName + } + writeFileSync(AUTH_PATH, JSON.stringify(auth, null, 2), { mode: 0o600 }) + + // Update kubeconfig + writeFileSync(KC_PATH, kubeconfig, { mode: 0o600 }) + + process.stderr.write(`Switched to workspace: ${match.id}\n`) + + return { + workspace: { uid: match.uid, id: match.id, teamName: match.teamName }, + kubeconfig_path: KC_PATH + } +} + +// ── Helpers ───────────────────────────────────────────── + +function loadAuth () { + if (!existsSync(AUTH_PATH)) { + throw new Error('Not authenticated. Please run: node sealos-auth.mjs login') + } + return JSON.parse(readFileSync(AUTH_PATH, 'utf-8')) +} + +// ── CLI ──────────────────────────────────────────────── + +const [, , cmd, ...rawArgs] = process.argv + +// --insecure flag: skip TLS certificate verification (for self-signed certs) +const insecure = rawArgs.includes('--insecure') +const args = rawArgs.filter(a => a !== '--insecure') + +if (insecure) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' +} + +try { + switch (cmd) { + case 'check': { + console.log(JSON.stringify(check())) + break + } + + case 'login': { + const region = args[0] || process.env.SEALOS_REGION || DEFAULT_REGION + const result = await login(region) + console.log(JSON.stringify(result)) + break + } + + case 'info': { + console.log(JSON.stringify(info(), null, 2)) + break + } + + case 'list': { + const result = await list() + console.log(JSON.stringify(result, null, 2)) + break + } + + case 'switch': { + const target = args[0] + const result = await switchWs(target) + console.log(JSON.stringify(result, null, 2)) + break + } + + default: { + console.log(`Sealos Cloud Auth — OAuth2 Device Grant Flow + +Usage: + node sealos-auth.mjs check Check authentication status + current workspace + node sealos-auth.mjs login [region] Start OAuth2 device login flow + node sealos-auth.mjs login --insecure Skip TLS verification (self-signed cert) + node sealos-auth.mjs list List all workspaces + node sealos-auth.mjs switch Switch workspace (by id, uid, or teamName) + node sealos-auth.mjs info Show current auth details + +Environment: + SEALOS_REGION Region URL (default: ${DEFAULT_REGION}) + +Flow: + 1. Run "login" → opens browser for authorization + 2. Approve in browser → script receives token automatically + 3. Token exchanged for regional token + kubeconfig → saved to ~/.sealos/`) + } + } +} catch (err) { + // If TLS error and not using --insecure, hint the user + if (!insecure && (err.message.includes('fetch failed') || err.message.includes('self-signed') || err.message.includes('CERT'))) { + console.error(JSON.stringify({ error: err.message, hint: 'Try adding --insecure for self-signed certificates' })) + } else { + console.error(JSON.stringify({ error: err.message })) + } + process.exit(1) +} diff --git a/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/validate-artifacts.mjs b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/validate-artifacts.mjs new file mode 100644 index 00000000..f0d3ed26 --- /dev/null +++ b/plugins/labring/sealos-skills/skills/sealos-deploy/scripts/validate-artifacts.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import fs from 'fs' +import path from 'path' +import { + inferArtifactKind, + validateArtifactFile, +} from './artifact-validator.mjs' + +function collectProjectArtifacts(workDir) { + const sealosDir = path.join(workDir, '.sealos') + const candidates = [ + path.join(sealosDir, 'config.json'), + path.join(sealosDir, 'analysis.json'), + path.join(sealosDir, 'build', 'build-result.json'), + path.join(sealosDir, 'state.json'), + ] + + return candidates + .filter((candidate) => fs.existsSync(candidate)) + .map((candidate) => ({ + file: candidate, + kind: inferArtifactKind(candidate), + })) + .filter((entry) => entry.kind) +} + +function printAndExit(result, code) { + console.log(JSON.stringify(result, null, 2)) + process.exit(code) +} + +const args = process.argv.slice(2) + +if (args.length === 0) { + printAndExit({ + valid: false, + error: 'Usage: node validate-artifacts.mjs | | --dir ', + }, 1) +} + +if (args[0] === '--dir') { + const workDir = args[1] + if (!workDir) { + printAndExit({ valid: false, error: 'Missing work directory after --dir' }, 1) + } + + const results = collectProjectArtifacts(path.resolve(workDir)).map(({ kind, file }) => ({ + file, + ...validateArtifactFile(kind, file), + })) + + printAndExit({ + valid: results.every((entry) => entry.valid), + results, + }, results.every((entry) => entry.valid) ? 0 : 1) +} + +let kind +let filePath + +if (args.length === 1) { + filePath = path.resolve(args[0]) + kind = inferArtifactKind(filePath) + if (!kind) { + printAndExit({ + valid: false, + error: `Could not infer artifact kind from filename: ${path.basename(filePath)}`, + }, 1) + } +} else { + kind = args[0] + filePath = path.resolve(args[1]) +} + +const result = validateArtifactFile(kind, filePath) +printAndExit({ + file: filePath, + ...result, +}, result.valid ? 0 : 1)