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 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-01-31 22:31:41 -07:00
parent f6ced22e06
commit c2b0447d80
14 changed files with 171 additions and 37 deletions

View File

@ -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:<tag> \
-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

View File

@ -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

View File

@ -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',
},
],
};

View File

@ -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

View File

@ -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"
}
}

View File

@ -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 },
],
},
};

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
packages:
- "packages/*"
- "apps/*"

View File

@ -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,
})
}

View File

@ -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", ""},
}

View File

@ -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)
}