diff --git a/internal/domain/component.go b/internal/domain/component.go index 9c2c216..ccda8f9 100644 --- a/internal/domain/component.go +++ b/internal/domain/component.go @@ -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 diff --git a/internal/domain/credential.go b/internal/domain/credential.go index 69f1e31..1dbd40f 100644 --- a/internal/domain/credential.go +++ b/internal/domain/credential.go @@ -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" ) diff --git a/internal/service/component.go b/internal/service/component.go index b6d00f6..3c493ca 100644 --- a/internal/service/component.go +++ b/internal/service/component.go @@ -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) diff --git a/internal/service/component_deploy.go b/internal/service/component_deploy.go index b4c7272..305ebb3 100644 --- a/internal/service/component_deploy.go +++ b/internal/service/component_deploy.go @@ -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)