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:
parent
f6ced22e06
commit
c2b0447d80
@ -155,9 +155,8 @@ curl -X POST "$RDEV_API_URL/projects/taskapp/components" \
|
|||||||
-H "X-API-Key: $RDEV_API_KEY" \
|
-H "X-API-Key: $RDEV_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"type": "app",
|
"type": "app-react",
|
||||||
"name": "dashboard",
|
"name": "dashboard"
|
||||||
"template": "app-react"
|
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -165,11 +164,10 @@ curl -X POST "$RDEV_API_URL/projects/taskapp/components" \
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
"type": "app",
|
"type": "app-react",
|
||||||
"name": "dashboard",
|
"name": "dashboard",
|
||||||
"path": "apps/dashboard",
|
"path": "apps/dashboard",
|
||||||
"port": 3001,
|
"port": 3001
|
||||||
"template": "app-react"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -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
|
```bash
|
||||||
curl -X POST "$RDEV_API_URL/projects/taskapp/deploy" \
|
# Example: deploy the api service with a specific image tag
|
||||||
-H "X-API-Key: $RDEV_API_KEY" \
|
kubectl set image deployment/taskapp-api \
|
||||||
-H "Content-Type: application/json" \
|
api=registry.threesix.ai/taskapp/api:<tag> \
|
||||||
-d '{}'
|
-n projects
|
||||||
```
|
|
||||||
|
|
||||||
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"}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -298,12 +291,13 @@ curl -s "$RDEV_API_URL/projects/taskapp/components" \
|
|||||||
|
|
||||||
## Component Types
|
## Component Types
|
||||||
|
|
||||||
| Type | Directory | Templates | Default Port |
|
| Type | Directory | Stack | Default Port |
|
||||||
|------|-----------|-----------|--------------|
|
|------|-----------|-------|--------------|
|
||||||
| service | `services/` | service | 8001+ |
|
| service | `services/` | go | 8001+ |
|
||||||
| worker | `workers/` | worker | N/A |
|
| worker | `workers/` | go | N/A |
|
||||||
| app | `apps/` | app-astro, app-react | 3001+ |
|
| app-astro | `apps/` | astro | 3001+ |
|
||||||
| cli | `cli/` | cli | N/A |
|
| app-react | `apps/` | react | 3001+ |
|
||||||
|
| cli | `cli/` | go | N/A |
|
||||||
|
|
||||||
Ports auto-increment as you add components of the same type.
|
Ports auto-increment as you add components of the same type.
|
||||||
|
|
||||||
@ -365,8 +359,10 @@ Cleanup:
|
|||||||
│ │
|
│ │
|
||||||
│ Git push → Woodpecker CI: │
|
│ Git push → Woodpecker CI: │
|
||||||
│ ├──► build-api (Kaniko → registry) │
|
│ ├──► build-api (Kaniko → registry) │
|
||||||
|
│ ├──► deploy-api (kubectl set image) │
|
||||||
│ ├──► build-dashboard (Kaniko → registry) │
|
│ ├──► build-dashboard (Kaniko → registry) │
|
||||||
│ └──► deploy (kubectl set image) │
|
│ ├──► deploy-dashboard (kubectl set image) │
|
||||||
|
│ └──► verify (confirm deployments) │
|
||||||
│ │
|
│ │
|
||||||
│ Components live at https://{slug}.threesix.ai │
|
│ Components live at https://{slug}.threesix.ai │
|
||||||
│ ├──► /api/* → services/api │
|
│ ├──► /api/* → services/api │
|
||||||
@ -384,9 +380,9 @@ Cleanup:
|
|||||||
curl -s "$RDEV_API_URL/projects/taskapp" \
|
curl -s "$RDEV_API_URL/projects/taskapp" \
|
||||||
-H "X-API-Key: $RDEV_API_KEY" | jq '.data'
|
-H "X-API-Key: $RDEV_API_KEY" | jq '.data'
|
||||||
|
|
||||||
# Check available templates
|
# Check available component templates
|
||||||
curl -s "$RDEV_API_URL/templates/components" \
|
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
|
### Build stuck in pending
|
||||||
|
|||||||
@ -94,7 +94,7 @@ run_flow() {
|
|||||||
|
|
||||||
# Step 3: Add frontend app
|
# Step 3: Add frontend app
|
||||||
print_header "Step 3: Adding 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ run_flow() {
|
|||||||
echo "$components" | jq '.data // .'
|
echo "$components" | jq '.data // .'
|
||||||
|
|
||||||
local comp_count
|
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
|
if [[ "$comp_count" -lt 2 ]]; then
|
||||||
print_warning "Expected 2 components, got $comp_count"
|
print_warning "Expected 2 components, got $comp_count"
|
||||||
else
|
else
|
||||||
|
|||||||
@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -16,3 +16,14 @@ build-{{COMPONENT_NAME}}:
|
|||||||
when:
|
when:
|
||||||
branch: main
|
branch: main
|
||||||
event: push
|
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
|
||||||
|
|||||||
@ -16,8 +16,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/tailwind": "^5.0.0",
|
"@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": "^3.2.0",
|
||||||
"prettier-plugin-astro": "^0.13.0"
|
"prettier-plugin-astro": "^0.13.0",
|
||||||
|
"tailwindcss": "^3.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -16,3 +16,14 @@ build-{{COMPONENT_NAME}}:
|
|||||||
when:
|
when:
|
||||||
branch: main
|
branch: main
|
||||||
event: push
|
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
|
||||||
|
|||||||
@ -16,3 +16,14 @@ build-{{COMPONENT_NAME}}:
|
|||||||
when:
|
when:
|
||||||
branch: main
|
branch: main
|
||||||
event: push
|
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
|
||||||
|
|||||||
@ -16,3 +16,14 @@ build-{{COMPONENT_NAME}}:
|
|||||||
when:
|
when:
|
||||||
branch: main
|
branch: main
|
||||||
event: push
|
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
|
||||||
|
|||||||
@ -10,10 +10,11 @@ clone:
|
|||||||
steps:
|
steps:
|
||||||
# COMPONENT_STEPS_BELOW - Do not remove this marker
|
# COMPONENT_STEPS_BELOW - Do not remove this marker
|
||||||
|
|
||||||
deploy:
|
verify:
|
||||||
image: bitnami/kubectl:latest
|
image: bitnami/kubectl:latest
|
||||||
commands:
|
commands:
|
||||||
- echo "Deploying {{PROJECT_NAME}}"
|
- echo "Pipeline complete for {{PROJECT_NAME}}"
|
||||||
|
- kubectl get deployments -n projects -l app={{PROJECT_NAME}} --no-headers || true
|
||||||
when:
|
when:
|
||||||
branch: main
|
branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/*"
|
||||||
|
- "apps/*"
|
||||||
@ -40,8 +40,9 @@ func (h *ProjectManagementHandler) Mount(r api.Router) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Template endpoints
|
// Template endpoints
|
||||||
r.Get("/templates", h.ListTemplates) // GET /templates - List available templates
|
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/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.
|
// 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,
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ func TestProjectManagementHandler_NilService(t *testing.T) {
|
|||||||
{"status", "GET", "/project/test", ""},
|
{"status", "GET", "/project/test", ""},
|
||||||
{"delete", "DELETE", "/project/test", ""},
|
{"delete", "DELETE", "/project/test", ""},
|
||||||
{"list templates", "GET", "/templates", ""},
|
{"list templates", "GET", "/templates", ""},
|
||||||
|
{"list component templates", "GET", "/templates/components", ""},
|
||||||
{"get template", "GET", "/templates/default", ""},
|
{"get template", "GET", "/templates/default", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -688,3 +688,11 @@ func (s *ProjectInfraService) GetTemplate(ctx context.Context, name string) (*po
|
|||||||
}
|
}
|
||||||
return s.templateProvider.GetTemplate(ctx, name)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user