fix: worker deployments and JWT_SECRET auto-provisioning
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
9be5c7d81b
commit
3247ce3ca0
@ -88,6 +88,14 @@ func (c ComponentType) NeedsPort() bool {
|
|||||||
return c == ComponentTypeService || c == ComponentTypeAppAstro || c == ComponentTypeAppReact || c == ComponentTypeAppNextJS
|
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).
|
// IsGoComponent returns true if this component type uses Go (and needs go.work entry).
|
||||||
func (c ComponentType) IsGoComponent() bool {
|
func (c ComponentType) IsGoComponent() bool {
|
||||||
return c == ComponentTypeService || c == ComponentTypeWorker || c == ComponentTypeCLI
|
return c == ComponentTypeService || c == ComponentTypeWorker || c == ComponentTypeCLI
|
||||||
|
|||||||
@ -77,4 +77,7 @@ const (
|
|||||||
|
|
||||||
// Resend (email provider for per-project domain provisioning)
|
// Resend (email provider for per-project domain provisioning)
|
||||||
CredKeyResendAPIKey = "RESEND_API_KEY"
|
CredKeyResendAPIKey = "RESEND_API_KEY"
|
||||||
|
|
||||||
|
// Project-scoped auth secret (unique per project, auto-generated on first code component)
|
||||||
|
CredKeyJWTSecret = "JWT_SECRET"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -237,7 +238,11 @@ func (s *ComponentService) AddComponent(ctx context.Context, projectID string, r
|
|||||||
Dependencies: []string{}, // Could be parsed from component.yaml
|
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.
|
// This ensures kubectl set image will find the deployment when CI runs.
|
||||||
s.createInitialComponentDeployment(ctx, projectID, projectDomain, component)
|
s.createInitialComponentDeployment(ctx, projectID, projectDomain, component)
|
||||||
|
|
||||||
@ -327,6 +332,58 @@ func (s *ComponentService) prepareMonorepoUpdates(
|
|||||||
return fileOps, nil
|
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.
|
// findFirstServiceComponent returns the first service component in a project, or nil.
|
||||||
func (s *ComponentService) findFirstServiceComponent(ctx context.Context, projectID string) *domain.Component {
|
func (s *ComponentService) findFirstServiceComponent(ctx context.Context, projectID string) *domain.Component {
|
||||||
components, err := s.ListComponents(ctx, projectID)
|
components, err := s.ListComponents(ctx, projectID)
|
||||||
|
|||||||
@ -23,7 +23,7 @@ func (s *ComponentService) createInitialComponentDeployment(
|
|||||||
component *domain.Component,
|
component *domain.Component,
|
||||||
) {
|
) {
|
||||||
// Skip if no deployer or component doesn't need a deployment
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,7 +67,8 @@ func (s *ComponentService) createInitialComponentDeployment(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add path to project's unified Ingress
|
// Add path to project's unified Ingress (only for components with an HTTP port)
|
||||||
|
if component.Port > 0 && basePath != "" {
|
||||||
serviceName := spec.DeploymentName()
|
serviceName := spec.DeploymentName()
|
||||||
if err := s.deployer.AddIngressPath(ctx, projectID, projectDomain, basePath, serviceName, component.Port); err != nil {
|
if err := s.deployer.AddIngressPath(ctx, projectID, projectDomain, basePath, serviceName, component.Port); err != nil {
|
||||||
log.Warn("failed to add ingress path for component",
|
log.Warn("failed to add ingress path for component",
|
||||||
@ -76,7 +77,8 @@ func (s *ComponentService) createInitialComponentDeployment(
|
|||||||
"path", basePath,
|
"path", basePath,
|
||||||
logging.FieldError, err,
|
logging.FieldError, err,
|
||||||
)
|
)
|
||||||
// Continue anyway - the deployment/service exist and CI will work
|
// Continue anyway - the deployment exists and CI will work
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("created initial component deployment",
|
log.Info("created initial component deployment",
|
||||||
@ -185,6 +187,7 @@ func (s *ComponentService) fetchProjectCredentials(ctx context.Context, projectI
|
|||||||
domain.CredKeyNotifyAPIKey,
|
domain.CredKeyNotifyAPIKey,
|
||||||
domain.CredKeyNotifyHost,
|
domain.CredKeyNotifyHost,
|
||||||
domain.CredKeyNotifyFrom,
|
domain.CredKeyNotifyFrom,
|
||||||
|
domain.CredKeyJWTSecret,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global credentials (stored without project prefix, shared across all projects)
|
// Global credentials (stored without project prefix, shared across all projects)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user