From c2b0447d80e3fe95a2eb3885aa362596d2c722c6 Mon Sep 17 00:00:00 2001 From: jordan Date: Sat, 31 Jan 2026 22:31:41 -0700 Subject: [PATCH] feat: add per-component deploy steps and component templates endpoint Add deploy-{name} CI steps to all component templates (app-astro, app-react, service, worker) so each component deploys independently via kubectl set image on merge to main. Replace the skeleton's generic deploy step with a verify step that confirms deployments. Add GET /templates/components endpoint for listing available component templates with optional type filter. Simplify component API by merging type+template into a single type field (e.g., "app-react" instead of type="app" template="app-react"). Include ESLint configs and pnpm-workspace.yaml in app templates. Co-Authored-By: Claude Opus 4.5 --- cookbooks/composable-app.md | 54 +++++++++---------- cookbooks/scripts/composable-test.sh | 4 +- .../components/app-astro/.eslintrc.cjs.tmpl | 21 ++++++++ .../app-astro/.woodpecker.step.yml.tmpl | 11 ++++ .../components/app-astro/package.json.tmpl | 8 ++- .../components/app-react/.eslintrc.cjs.tmpl | 18 +++++++ .../app-react/.woodpecker.step.yml.tmpl | 11 ++++ .../service/.woodpecker.step.yml.tmpl | 11 ++++ .../worker/.woodpecker.step.yml.tmpl | 11 ++++ .../templates/skeleton/.woodpecker.yml.tmpl | 5 +- .../skeleton/pnpm-workspace.yaml.tmpl | 3 ++ internal/handlers/project_management.go | 42 ++++++++++++++- internal/handlers/project_management_test.go | 1 + internal/service/project_infra_crud.go | 8 +++ 14 files changed, 171 insertions(+), 37 deletions(-) create mode 100644 internal/adapter/templates/templates/components/app-astro/.eslintrc.cjs.tmpl create mode 100644 internal/adapter/templates/templates/components/app-react/.eslintrc.cjs.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pnpm-workspace.yaml.tmpl diff --git a/cookbooks/composable-app.md b/cookbooks/composable-app.md index 41a9ec9..e060b53 100644 --- a/cookbooks/composable-app.md +++ b/cookbooks/composable-app.md @@ -155,9 +155,8 @@ curl -X POST "$RDEV_API_URL/projects/taskapp/components" \ -H "X-API-Key: $RDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "type": "app", - "name": "dashboard", - "template": "app-react" + "type": "app-react", + "name": "dashboard" }' ``` @@ -165,11 +164,10 @@ curl -X POST "$RDEV_API_URL/projects/taskapp/components" \ ```json { "data": { - "type": "app", + "type": "app-react", "name": "dashboard", "path": "apps/dashboard", - "port": 3001, - "template": "app-react" + "port": 3001 } } ``` @@ -213,22 +211,17 @@ curl -s "$RDEV_API_URL/builds/{task_id}" \ --- -## Step 5: Deploy All Components +## Step 5: Deployment (CI-Driven) + +Each component's CI step includes a `deploy-{name}` phase that runs `kubectl set image` on merge to `main`. Pushing code triggers the full build-and-deploy pipeline automatically. + +To manually deploy a single component outside of CI: ```bash -curl -X POST "$RDEV_API_URL/projects/taskapp/deploy" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{}' -``` - -This deploys all components to K8s, or deploy a single component: - -```bash -curl -X POST "$RDEV_API_URL/projects/taskapp/deploy" \ - -H "X-API-Key: $RDEV_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"component": "services/api"}' +# Example: deploy the api service with a specific image tag +kubectl set image deployment/taskapp-api \ + api=registry.threesix.ai/taskapp/api: \ + -n projects ``` --- @@ -298,12 +291,13 @@ curl -s "$RDEV_API_URL/projects/taskapp/components" \ ## Component Types -| Type | Directory | Templates | Default Port | -|------|-----------|-----------|--------------| -| service | `services/` | service | 8001+ | -| worker | `workers/` | worker | N/A | -| app | `apps/` | app-astro, app-react | 3001+ | -| cli | `cli/` | cli | N/A | +| Type | Directory | Stack | Default Port | +|------|-----------|-------|--------------| +| service | `services/` | go | 8001+ | +| worker | `workers/` | go | N/A | +| app-astro | `apps/` | astro | 3001+ | +| app-react | `apps/` | react | 3001+ | +| cli | `cli/` | go | N/A | Ports auto-increment as you add components of the same type. @@ -365,8 +359,10 @@ Cleanup: │ │ │ Git push → Woodpecker CI: │ │ ├──► build-api (Kaniko → registry) │ +│ ├──► deploy-api (kubectl set image) │ │ ├──► build-dashboard (Kaniko → registry) │ -│ └──► deploy (kubectl set image) │ +│ ├──► deploy-dashboard (kubectl set image) │ +│ └──► verify (confirm deployments) │ │ │ │ Components live at https://{slug}.threesix.ai │ │ ├──► /api/* → services/api │ @@ -384,9 +380,9 @@ Cleanup: curl -s "$RDEV_API_URL/projects/taskapp" \ -H "X-API-Key: $RDEV_API_KEY" | jq '.data' -# Check available templates +# Check available component templates curl -s "$RDEV_API_URL/templates/components" \ - -H "X-API-Key: $RDEV_API_KEY" | jq '.data' + -H "X-API-Key: $RDEV_API_KEY" | jq '.data.components' ``` ### Build stuck in pending diff --git a/cookbooks/scripts/composable-test.sh b/cookbooks/scripts/composable-test.sh index 714745a..a9b519d 100755 --- a/cookbooks/scripts/composable-test.sh +++ b/cookbooks/scripts/composable-test.sh @@ -94,7 +94,7 @@ run_flow() { # Step 3: Add frontend app print_header "Step 3: Adding frontend app" - if ! add_component "app" "web" "app-react"; then + if ! add_component "app-react" "web"; then exit 1 fi @@ -105,7 +105,7 @@ run_flow() { echo "$components" | jq '.data // .' local comp_count - comp_count=$(echo "$components" | jq '.data | length // 0') + comp_count=$(echo "$components" | jq '.data.components | length // 0') if [[ "$comp_count" -lt 2 ]]; then print_warning "Expected 2 components, got $comp_count" else diff --git a/internal/adapter/templates/templates/components/app-astro/.eslintrc.cjs.tmpl b/internal/adapter/templates/templates/components/app-astro/.eslintrc.cjs.tmpl new file mode 100644 index 0000000..99b08a3 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-astro/.eslintrc.cjs.tmpl @@ -0,0 +1,21 @@ +module.exports = { + root: true, + extends: [ + 'eslint:recommended', + 'plugin:astro/recommended', + ], + overrides: [ + { + files: ['*.astro'], + parser: 'astro-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser', + extraFileExtensions: ['.astro'], + }, + }, + { + files: ['*.ts', '*.mts'], + parser: '@typescript-eslint/parser', + }, + ], +}; diff --git a/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl index f8bd6d7..63400b0 100644 --- a/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl @@ -16,3 +16,14 @@ build-{{COMPONENT_NAME}}: when: branch: main event: push + +deploy-{{COMPONENT_NAME}}: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} + {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} + -n projects + || echo "No deployment found for {{COMPONENT_NAME}}, skipping" + when: + branch: main + event: push diff --git a/internal/adapter/templates/templates/components/app-astro/package.json.tmpl b/internal/adapter/templates/templates/components/app-astro/package.json.tmpl index b4564d2..10b9dbd 100644 --- a/internal/adapter/templates/templates/components/app-astro/package.json.tmpl +++ b/internal/adapter/templates/templates/components/app-astro/package.json.tmpl @@ -16,8 +16,12 @@ }, "devDependencies": { "@astrojs/tailwind": "^5.0.0", - "tailwindcss": "^3.4.0", + "@typescript-eslint/parser": "^7.13.1", + "astro-eslint-parser": "^0.16.0", + "eslint": "^8.57.0", + "eslint-plugin-astro": "^0.34.0", "prettier": "^3.2.0", - "prettier-plugin-astro": "^0.13.0" + "prettier-plugin-astro": "^0.13.0", + "tailwindcss": "^3.4.0" } } diff --git a/internal/adapter/templates/templates/components/app-react/.eslintrc.cjs.tmpl b/internal/adapter/templates/templates/components/app-react/.eslintrc.cjs.tmpl new file mode 100644 index 0000000..4c99537 --- /dev/null +++ b/internal/adapter/templates/templates/components/app-react/.eslintrc.cjs.tmpl @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +}; diff --git a/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl index dc98d77..bcd3b0b 100644 --- a/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl @@ -16,3 +16,14 @@ build-{{COMPONENT_NAME}}: when: branch: main event: push + +deploy-{{COMPONENT_NAME}}: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} + {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} + -n projects + || echo "No deployment found for {{COMPONENT_NAME}}, skipping" + when: + branch: main + event: push diff --git a/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl index a825e3f..7e75a7b 100644 --- a/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl @@ -16,3 +16,14 @@ build-{{COMPONENT_NAME}}: when: branch: main event: push + +deploy-{{COMPONENT_NAME}}: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} + {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} + -n projects + || echo "No deployment found for {{COMPONENT_NAME}}, skipping" + when: + branch: main + event: push diff --git a/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl index e5fd57c..d1c4f50 100644 --- a/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl @@ -16,3 +16,14 @@ build-{{COMPONENT_NAME}}: when: branch: main event: push + +deploy-{{COMPONENT_NAME}}: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} + {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} + -n projects + || echo "No deployment found for {{COMPONENT_NAME}}, skipping" + when: + branch: main + event: push diff --git a/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl b/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl index 23f8e8b..77bad2b 100644 --- a/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl +++ b/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl @@ -10,10 +10,11 @@ clone: steps: # COMPONENT_STEPS_BELOW - Do not remove this marker - deploy: + verify: image: bitnami/kubectl:latest commands: - - echo "Deploying {{PROJECT_NAME}}" + - echo "Pipeline complete for {{PROJECT_NAME}}" + - kubectl get deployments -n projects -l app={{PROJECT_NAME}} --no-headers || true when: branch: main event: push diff --git a/internal/adapter/templates/templates/skeleton/pnpm-workspace.yaml.tmpl b/internal/adapter/templates/templates/skeleton/pnpm-workspace.yaml.tmpl new file mode 100644 index 0000000..0e5a073 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pnpm-workspace.yaml.tmpl @@ -0,0 +1,3 @@ +packages: + - "packages/*" + - "apps/*" diff --git a/internal/handlers/project_management.go b/internal/handlers/project_management.go index 092a5dd..3e1aa28 100644 --- a/internal/handlers/project_management.go +++ b/internal/handlers/project_management.go @@ -40,8 +40,9 @@ func (h *ProjectManagementHandler) Mount(r api.Router) { }) // Template endpoints - r.Get("/templates", h.ListTemplates) // GET /templates - List available templates - r.Get("/templates/{name}", h.GetTemplate) // GET /templates/{name} - Get template details + r.Get("/templates", h.ListTemplates) // GET /templates - List available templates + r.Get("/templates/components", h.ListComponentTemplates) // GET /templates/components - List component templates + r.Get("/templates/{name}", h.GetTemplate) // GET /templates/{name} - Get template details } // CreateRequest is the request body for POST /project. @@ -292,3 +293,40 @@ func (h *ProjectManagementHandler) GetTemplate(w http.ResponseWriter, r *http.Re "files": template.Files, }) } + +// ListComponentTemplates returns available component templates. +// GET /templates/components +func (h *ProjectManagementHandler) ListComponentTemplates(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) + defer cancel() + + if h.infraService == nil { + api.WriteInternalError(w, r, "project infrastructure service not configured") + return + } + + componentType := r.URL.Query().Get("type") + + components, err := h.infraService.ListComponentTemplates(ctx, componentType) + if err != nil { + h.logger.Error("failed to list component templates", "error", err) + api.WriteInternalError(w, r, "failed to list component templates") + return + } + + response := make([]map[string]any, len(components)) + for i, c := range components { + response[i] = map[string]any{ + "type": c.Type, + "description": c.Description, + "stack": c.Stack, + "default_port": c.DefaultPort, + "dest_dir": c.DestDir, + "files": c.Files, + } + } + + api.WriteSuccess(w, r, map[string]any{ + "components": response, + }) +} diff --git a/internal/handlers/project_management_test.go b/internal/handlers/project_management_test.go index d639cec..68fe6c5 100644 --- a/internal/handlers/project_management_test.go +++ b/internal/handlers/project_management_test.go @@ -27,6 +27,7 @@ func TestProjectManagementHandler_NilService(t *testing.T) { {"status", "GET", "/project/test", ""}, {"delete", "DELETE", "/project/test", ""}, {"list templates", "GET", "/templates", ""}, + {"list component templates", "GET", "/templates/components", ""}, {"get template", "GET", "/templates/default", ""}, } diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go index 5131f95..b060f4f 100644 --- a/internal/service/project_infra_crud.go +++ b/internal/service/project_infra_crud.go @@ -688,3 +688,11 @@ func (s *ProjectInfraService) GetTemplate(ctx context.Context, name string) (*po } return s.templateProvider.GetTemplate(ctx, name) } + +// ListComponentTemplates returns available component templates, optionally filtered by type. +func (s *ProjectInfraService) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) { + if s.templateProvider == nil { + return nil, fmt.Errorf("template provider not configured") + } + return s.templateProvider.ListComponentTemplates(ctx, componentType) +}