fix: worker deployments and JWT_SECRET auto-provisioning
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

RC-1: Workers now get a Kubernetes Deployment on component creation.
NeedsPort() (port assignment) was incorrectly used to gate Deployment
creation - workers have no HTTP port but still need a Deployment so
CI `kubectl set image` can succeed. Added NeedsDeployment() returning
true for service/worker/app-react/app-astro/app-nextjs. AddIngressPath
is now guarded by port > 0 so workers don't attempt HTTP routing.

RC-2: JWT_SECRET is now auto-provisioned per-project when the first
code component is added. The skeleton service template fatally requires
JWT_SECRET at startup; previously fetchProjectCredentials() never fetched
it. ensureProjectJWTSecret() generates a cryptographically random 32-byte
secret, stores it as "{projectID}:JWT_SECRET", and JWT_SECRET is now
included in projectScopedKeys so it's injected into every deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-23 03:42:53 -07:00
parent 9be5c7d81b
commit 3247ce3ca0
4 changed files with 83 additions and 12 deletions

View File

@ -88,6 +88,14 @@ func (c ComponentType) NeedsPort() bool {
return c == ComponentTypeService || c == ComponentTypeAppAstro || c == ComponentTypeAppReact || c == ComponentTypeAppNextJS
}
// NeedsDeployment returns true if this component type requires a Kubernetes Deployment.
// All code components except CLI need a Deployment so CI can use kubectl set image.
// Workers have no HTTP port but still need a Deployment to run as background processes.
func (c ComponentType) NeedsDeployment() bool {
return c == ComponentTypeService || c == ComponentTypeWorker ||
c == ComponentTypeAppAstro || c == ComponentTypeAppReact || c == ComponentTypeAppNextJS
}
// IsGoComponent returns true if this component type uses Go (and needs go.work entry).
func (c ComponentType) IsGoComponent() bool {
return c == ComponentTypeService || c == ComponentTypeWorker || c == ComponentTypeCLI

View File

@ -77,4 +77,7 @@ const (
// Resend (email provider for per-project domain provisioning)
CredKeyResendAPIKey = "RESEND_API_KEY"
// Project-scoped auth secret (unique per project, auto-generated on first code component)
CredKeyJWTSecret = "JWT_SECRET"
)

View File

@ -3,6 +3,7 @@ package service
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
@ -237,7 +238,11 @@ func (s *ComponentService) AddComponent(ctx context.Context, projectID string, r
Dependencies: []string{}, // Could be parsed from component.yaml
}
// 13. Create initial K8s deployment for components that need one.
// 13. Ensure a JWT_SECRET exists for this project (required by skeleton service startup).
// Generated once per project on the first code component; reused for all subsequent components.
s.ensureProjectJWTSecret(ctx, projectID)
// 14. Create initial K8s deployment for components that need one.
// This ensures kubectl set image will find the deployment when CI runs.
s.createInitialComponentDeployment(ctx, projectID, projectDomain, component)
@ -327,6 +332,58 @@ func (s *ComponentService) prepareMonorepoUpdates(
return fileOps, nil
}
// ensureProjectJWTSecret generates and stores a random JWT_SECRET for the project
// if one does not already exist. Called on first code component add.
// The secret is stored as "{projectID}:JWT_SECRET" in the credential store.
func (s *ComponentService) ensureProjectJWTSecret(ctx context.Context, projectID string) {
if s.credentialStore == nil {
return
}
log := logging.FromContext(ctx).WithService("component")
key := projectID + ":" + domain.CredKeyJWTSecret
existing, err := s.credentialStore.Get(ctx, key)
if err != nil {
log.Warn("failed to check JWT secret existence",
logging.FieldProjectID, projectID,
logging.FieldError, err,
)
return
}
if existing != "" {
return // Already provisioned - don't overwrite
}
// Generate a cryptographically random 32-byte secret
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
log.Warn("failed to generate JWT secret",
logging.FieldProjectID, projectID,
logging.FieldError, err,
)
return
}
secretValue := base64.URLEncoding.EncodeToString(secret)
if err := s.credentialStore.Set(ctx, domain.Credential{
Key: key,
Value: secretValue,
Description: "JWT signing secret for project " + projectID,
Category: "project",
}); err != nil {
log.Warn("failed to store JWT secret",
logging.FieldProjectID, projectID,
logging.FieldError, err,
)
return
}
log.Info("provisioned JWT secret for project",
logging.FieldProjectID, projectID,
)
}
// findFirstServiceComponent returns the first service component in a project, or nil.
func (s *ComponentService) findFirstServiceComponent(ctx context.Context, projectID string) *domain.Component {
components, err := s.ListComponents(ctx, projectID)

View File

@ -23,7 +23,7 @@ func (s *ComponentService) createInitialComponentDeployment(
component *domain.Component,
) {
// Skip if no deployer or component doesn't need a deployment
if s.deployer == nil || !component.Type.NeedsPort() {
if s.deployer == nil || !component.Type.NeedsDeployment() {
return
}
@ -67,16 +67,18 @@ func (s *ComponentService) createInitialComponentDeployment(
return
}
// Add path to project's unified Ingress
serviceName := spec.DeploymentName()
if err := s.deployer.AddIngressPath(ctx, projectID, projectDomain, basePath, serviceName, component.Port); err != nil {
log.Warn("failed to add ingress path for component",
logging.FieldProjectID, projectID,
"component", component.Name,
"path", basePath,
logging.FieldError, err,
)
// Continue anyway - the deployment/service exist and CI will work
// Add path to project's unified Ingress (only for components with an HTTP port)
if component.Port > 0 && basePath != "" {
serviceName := spec.DeploymentName()
if err := s.deployer.AddIngressPath(ctx, projectID, projectDomain, basePath, serviceName, component.Port); err != nil {
log.Warn("failed to add ingress path for component",
logging.FieldProjectID, projectID,
"component", component.Name,
"path", basePath,
logging.FieldError, err,
)
// Continue anyway - the deployment exists and CI will work
}
}
log.Info("created initial component deployment",
@ -185,6 +187,7 @@ func (s *ComponentService) fetchProjectCredentials(ctx context.Context, projectI
domain.CredKeyNotifyAPIKey,
domain.CredKeyNotifyHost,
domain.CredKeyNotifyFrom,
domain.CredKeyJWTSecret,
}
// Global credentials (stored without project prefix, shared across all projects)