Compare commits

...

3 Commits

Author SHA1 Message Date
jordan
3247ce3ca0 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>
2026-02-23 03:42:53 -07:00
jordan
9be5c7d81b fix: address code review issues in album and generation skeleton packages
- Add ErrAnchorRequired sentinel to pkg/album — replaces fragile string equality check
  used for 422 detection; callers now use errors.Is()
- Extract parseShotIndex() helper in album handler — replaces fmt.Sscanf which silently
  accepted partial parses like "12abc"; strconv.Atoi requires the full string to be numeric
- Restructure caption saves in album/handler — captions now written outside the
  len(img.Data) > 0 gate, so URL-only providers (no bytes returned) still get captions
- Add storage.FetchURL() shared utility — removes fetchBytes/downloadURL duplication
  across album and generation packages; callers control timeout via their http.Client
- Add video captions to VideoHandler — same caption sidecar pattern applied to videos
- Add persona generation event types to realtime package — persona_spec_*, persona_image_*,
  persona_video_* events added to EventType union and usePersonaGeneration hook exported

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 03:01:37 -07:00
jordan
062a828a00 feat: save prompt caption alongside every generated image
After each successful image upload to storage, a sidecar `.caption`
file is uploaded at the same path with `.caption` extension containing
the exact prompt used to generate the image.

Coverage:
- generation/handlers.go: ImageHandler → media/{userID}/images/{jobID}_{i}.caption
- album/handler.go: AnchorHandler → albums/{userID}/{albumID}/anchor.caption
- album/handler.go: ShotHandler → albums/{userID}/{albumID}/shots/{shotIndex}.caption
- personagen/service.go: generatePosition → personas/{specID}/images/{pos:02d}.caption

Caption failures are logged at warn level and never abort the job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 02:38:08 -07:00
13 changed files with 212 additions and 91 deletions

View File

@ -1,8 +1,9 @@
package handlers package handlers
import ( import (
"fmt" "errors"
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -207,7 +208,7 @@ func (h *Album) GenerateAllShots(w http.ResponseWriter, r *http.Request) error {
jobIDs, err := h.albums.GenerateAllShots(r.Context(), id, user.ID) jobIDs, err := h.albums.GenerateAllShots(r.Context(), id, user.ID)
if err != nil { if err != nil {
if err.Error() == "anchor must be generated before shots" { if errors.Is(err, album.ErrAnchorRequired) {
return httperror.UnprocessableEntity("anchor must be generated before shots") return httperror.UnprocessableEntity("anchor must be generated before shots")
} }
return httperror.NotFound("album not found") return httperror.NotFound("album not found")
@ -234,16 +235,14 @@ func (h *Album) GenerateShot(w http.ResponseWriter, r *http.Request) error {
return httperror.BadRequest("album ID is required") return httperror.BadRequest("album ID is required")
} }
shotIndex := 0 shotIndex, err := parseShotIndex(chi.URLParam(r, "index"))
if idx := chi.URLParam(r, "index"); idx != "" { if err != nil {
if _, err := fmt.Sscanf(idx, "%d", &shotIndex); err != nil { return httperror.BadRequest("shot index must be a non-negative integer")
return httperror.BadRequest("invalid shot index")
}
} }
jobID, err := h.albums.GenerateShot(r.Context(), id, user.ID, shotIndex) jobID, err := h.albums.GenerateShot(r.Context(), id, user.ID, shotIndex)
if err != nil { if err != nil {
if err.Error() == "anchor must be generated before shots" { if errors.Is(err, album.ErrAnchorRequired) {
return httperror.UnprocessableEntity("anchor must be generated before shots") return httperror.UnprocessableEntity("anchor must be generated before shots")
} }
return httperror.NotFound("album or shot not found") return httperror.NotFound("album or shot not found")
@ -265,11 +264,9 @@ func (h *Album) ResetShot(w http.ResponseWriter, r *http.Request) error {
return httperror.BadRequest("album ID is required") return httperror.BadRequest("album ID is required")
} }
shotIndex := 0 shotIndex, err := parseShotIndex(chi.URLParam(r, "index"))
if idx := chi.URLParam(r, "index"); idx != "" { if err != nil {
if _, err := fmt.Sscanf(idx, "%d", &shotIndex); err != nil { return httperror.BadRequest("shot index must be a non-negative integer")
return httperror.BadRequest("invalid shot index")
}
} }
if err := h.albums.ResetShot(r.Context(), id, user.ID, shotIndex); err != nil { if err := h.albums.ResetShot(r.Context(), id, user.ID, shotIndex); err != nil {
@ -279,3 +276,16 @@ func (h *Album) ResetShot(w http.ResponseWriter, r *http.Request) error {
httpresponse.NoContent(w) httpresponse.NoContent(w)
return nil return nil
} }
// parseShotIndex parses and validates the shot index URL parameter.
// Returns an error if the value is missing, non-numeric, or negative.
func parseShotIndex(idx string) (int, error) {
if idx == "" {
return 0, errors.New("missing shot index")
}
n, err := strconv.Atoi(idx)
if err != nil || n < 0 {
return 0, errors.New("shot index must be a non-negative integer")
}
return n, nil
}

View File

@ -122,7 +122,7 @@ func (s *AlbumService) GenerateAllShots(ctx context.Context, id album.AlbumID, u
return nil, fmt.Errorf("album not found: %w", err) return nil, fmt.Errorf("album not found: %w", err)
} }
if a.AnchorURL == "" { if a.AnchorURL == "" {
return nil, fmt.Errorf("anchor must be generated before shots") return nil, album.ErrAnchorRequired
} }
var jobIDs []string var jobIDs []string

View File

@ -4,3 +4,4 @@ export { useMediaGeneration, type GenerationStatus, type UseMediaGenerationConfi
export { useChat, type UseChatConfig, type UseChatResult, type ChatMessage } from './useChat'; export { useChat, type UseChatConfig, type UseChatResult, type ChatMessage } from './useChat';
export { useMediaUpload, type UploadProgress, type UploadResult, type UseMediaUploadConfig, type UseMediaUploadResult } from './useMediaUpload'; export { useMediaUpload, type UploadProgress, type UploadResult, type UseMediaUploadConfig, type UseMediaUploadResult } from './useMediaUpload';
export { useAlbumGeneration, type Album, type Shot, type ShotStatus, type UseAlbumGenerationConfig, type UseAlbumGenerationResult } from './useAlbumGeneration'; export { useAlbumGeneration, type Album, type Shot, type ShotStatus, type UseAlbumGenerationConfig, type UseAlbumGenerationResult } from './useAlbumGeneration';
export { usePersonaGeneration, type PersonaGenerationState, type UsePersonaGenerationConfig, type UsePersonaGenerationResult } from './usePersonaGeneration';

View File

@ -22,7 +22,17 @@ export type EventType =
| 'album_anchor_failed' | 'album_anchor_failed'
| 'album_shot_generating' | 'album_shot_generating'
| 'album_shot_complete' | 'album_shot_complete'
| 'album_shot_failed'; | 'album_shot_failed'
// Persona generation events (pkg/personagen)
| 'persona_spec_started'
| 'persona_spec_complete'
| 'persona_image_started'
| 'persona_image_progress'
| 'persona_image_complete'
| 'persona_video_started'
| 'persona_video_complete'
| 'persona_video_failed'
| 'persona_failed';
/** /**
* Chat message data payload. * Chat message data payload.

View File

@ -3,7 +3,6 @@ package album
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@ -76,13 +75,23 @@ func AnchorHandler(mg *mediagen.Manager, store storage.Store, pub realtime.Event
// Persist anchor to storage. // Persist anchor to storage.
anchorURL := img.URL anchorURL := img.URL
if store != nil && len(img.Data) > 0 { if store != nil {
storagePath := fmt.Sprintf("albums/%s/%s/anchor.png", userID, albumID) if len(img.Data) > 0 {
url, uploadErr := store.Upload(ctx, storagePath, img.Data, "image/png") storagePath := fmt.Sprintf("albums/%s/%s/anchor.png", userID, albumID)
if uploadErr != nil { url, uploadErr := store.Upload(ctx, storagePath, img.Data, "image/png")
logger.Warn("failed to persist anchor to storage, using inline URL", "error", uploadErr, "job_id", job.ID) if uploadErr != nil {
} else { logger.Warn("failed to persist anchor to storage, using inline URL", "error", uploadErr, "job_id", job.ID)
anchorURL = url } else {
anchorURL = url
}
}
// Save caption alongside the image regardless of whether bytes were uploaded.
// This ensures we always record what generated the image (URL-only providers included).
if anchorURL != "" {
captionPath := fmt.Sprintf("albums/%s/%s/anchor.caption", userID, albumID)
if _, captionErr := store.Upload(ctx, captionPath, []byte(subjectDesc), "text/plain"); captionErr != nil {
logger.Warn("failed to persist anchor caption", "error", captionErr, "job_id", job.ID)
}
} }
} }
@ -146,10 +155,11 @@ func ShotHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPu
}) })
// Fetch anchor image bytes from storage. // Fetch anchor image bytes from storage.
const anchorMaxBytes = 20 << 20 // 20 MB — anchor images should be small PNGs
var anchorBytes []byte var anchorBytes []byte
var anchorMime string var anchorMime string
if anchorURL != "" { if anchorURL != "" {
data, err := fetchBytes(ctx, anchorURL) data, err := storage.FetchURL(ctx, httpClient, anchorURL, anchorMaxBytes)
if err != nil { if err != nil {
logger.Warn("failed to fetch anchor image, proceeding without reference", logger.Warn("failed to fetch anchor image, proceeding without reference",
"error", err, "job_id", job.ID, "anchor_url", anchorURL) "error", err, "job_id", job.ID, "anchor_url", anchorURL)
@ -205,14 +215,23 @@ func ShotHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPu
// Persist shot image to storage. // Persist shot image to storage.
imageURL := img.URL imageURL := img.URL
if store != nil && len(img.Data) > 0 { if store != nil {
storagePath := fmt.Sprintf("albums/%s/%s/shots/%d.png", userID, albumID, shotIndex) if len(img.Data) > 0 {
url, uploadErr := store.Upload(ctx, storagePath, img.Data, "image/png") storagePath := fmt.Sprintf("albums/%s/%s/shots/%d.png", userID, albumID, shotIndex)
if uploadErr != nil { url, uploadErr := store.Upload(ctx, storagePath, img.Data, "image/png")
logger.Warn("failed to persist shot to storage, using inline URL", if uploadErr != nil {
"error", uploadErr, "job_id", job.ID) logger.Warn("failed to persist shot to storage, using inline URL",
} else { "error", uploadErr, "job_id", job.ID)
imageURL = url } else {
imageURL = url
}
}
// Save caption alongside the image regardless of whether bytes were uploaded.
if imageURL != "" {
captionPath := fmt.Sprintf("albums/%s/%s/shots/%d.caption", userID, albumID, shotIndex)
if _, captionErr := store.Upload(ctx, captionPath, []byte(prompt), "text/plain"); captionErr != nil {
logger.Warn("failed to persist shot caption", "error", captionErr, "job_id", job.ID)
}
} }
} }
@ -241,25 +260,3 @@ func ShotHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPu
} }
} }
// fetchBytes downloads raw bytes from a URL.
// Used to load anchor images at shot-generation time.
func fetchBytes(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch: HTTP %d", resp.StatusCode)
}
const maxSize = 20 << 20 // 20 MB limit for anchor images
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
return data, nil
}

View File

@ -13,9 +13,14 @@ package album
import ( import (
"context" "context"
"errors"
"time" "time"
) )
// ErrAnchorRequired is returned when shot generation is attempted before the anchor image exists.
// Handlers should map this to HTTP 422 Unprocessable Entity.
var ErrAnchorRequired = errors.New("anchor must be generated before shots")
// AlbumID is the unique identifier for an album. // AlbumID is the unique identifier for an album.
type AlbumID string type AlbumID string

View File

@ -6,7 +6,6 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@ -100,6 +99,10 @@ func ImageHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventP
if uploadErr != nil { if uploadErr != nil {
logger.Warn("failed to persist image to storage", "error", uploadErr, "job_id", job.ID) logger.Warn("failed to persist image to storage", "error", uploadErr, "job_id", job.ID)
} else { } else {
captionPath := fmt.Sprintf("media/%s/images/%s_%d.caption", userID, job.ID, i)
if _, captionErr := store.Upload(ctx, captionPath, []byte(prompt), "text/plain"); captionErr != nil {
logger.Warn("failed to persist image caption", "error", captionErr, "job_id", job.ID)
}
images[i] = GeneratedImage{Data: url, IsURL: true, Seed: resp.Seed} images[i] = GeneratedImage{Data: url, IsURL: true, Seed: resp.Seed}
continue continue
} }
@ -175,6 +178,7 @@ func VideoHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventP
// Build videos array matching frontend VideoResult shape: // Build videos array matching frontend VideoResult shape:
// { videos: [{ data, isUrl, mimeType }], provider, latencyMs } // { videos: [{ data, isUrl, mimeType }], provider, latencyMs }
const videoMaxBytes = 500 << 20 // 500 MB — videos can be large
videos := make([]map[string]any, 0, len(resp.Videos)) videos := make([]map[string]any, 0, len(resp.Videos))
for i, vid := range resp.Videos { for i, vid := range resp.Videos {
videoURL := vid.URL videoURL := vid.URL
@ -189,7 +193,7 @@ func VideoHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventP
if len(vid.Data) > 0 { if len(vid.Data) > 0 {
videoData = vid.Data videoData = vid.Data
} else if vid.URL != "" { } else if vid.URL != "" {
downloaded, downloadErr := downloadURL(ctx, vid.URL) downloaded, downloadErr := storage.FetchURL(ctx, httpClient, vid.URL, videoMaxBytes)
if downloadErr != nil { if downloadErr != nil {
logger.Warn("failed to download video from provider", "error", downloadErr, "job_id", job.ID) logger.Warn("failed to download video from provider", "error", downloadErr, "job_id", job.ID)
} else { } else {
@ -205,6 +209,14 @@ func VideoHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventP
videoURL = persistedURL videoURL = persistedURL
} }
} }
// Save caption alongside the video regardless of where it's stored.
if videoURL != "" && prompt != "" {
captionPath := fmt.Sprintf("media/%s/videos/%s_%d.caption", userID, job.ID, i)
if _, captionErr := store.Upload(ctx, captionPath, []byte(prompt), "text/plain"); captionErr != nil {
logger.Warn("failed to persist video caption", "error", captionErr, "job_id", job.ID)
}
}
} }
videos = append(videos, map[string]any{ videos = append(videos, map[string]any{
@ -335,26 +347,3 @@ func ChatResponseHandler(tg *textgen.Manager, pub realtime.EventPublisher, logge
} }
} }
// downloadURL fetches content from a URL and returns the bytes.
// Used to download provider-hosted videos before persisting to storage.
func downloadURL(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download: status %d", resp.StatusCode)
}
// Limit body to 500MB to prevent OOM from unexpected large responses.
const maxBodySize = 500 << 20
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return data, nil
}

View File

@ -228,6 +228,13 @@ func (s *Service) generatePosition(ctx context.Context, spec *persona.PersonaSpe
return fmt.Errorf("storing position %d: %w", pos, err) return fmt.Errorf("storing position %d: %w", pos, err)
} }
if imgSpec.Prompt != "" {
captionPath := fmt.Sprintf("personas/%s/images/%02d.caption", spec.ID, pos)
if _, captionErr := s.store.Upload(ctx, captionPath, []byte(imgSpec.Prompt), "text/plain"); captionErr != nil {
s.logger.Warn("failed to persist image caption", "error", captionErr, "position", pos)
}
}
imgSpec.URL = url imgSpec.URL = url
imgSpec.Status = persona.ImageStatusComplete imgSpec.Status = persona.ImageStatusComplete
return nil return nil

View File

@ -0,0 +1,31 @@
package storage
import (
"context"
"fmt"
"io"
"net/http"
)
// FetchURL downloads content from a URL using the provided HTTP client.
// maxBytes caps the download to prevent OOM from unexpectedly large responses.
// Callers control the timeout via the http.Client they pass in.
func FetchURL(ctx context.Context, client *http.Client, url string, maxBytes int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch: HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes))
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
return data, nil
}

View File

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

View File

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

View File

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

View File

@ -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,16 +67,18 @@ 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)
serviceName := spec.DeploymentName() if component.Port > 0 && basePath != "" {
if err := s.deployer.AddIngressPath(ctx, projectID, projectDomain, basePath, serviceName, component.Port); err != nil { serviceName := spec.DeploymentName()
log.Warn("failed to add ingress path for component", if err := s.deployer.AddIngressPath(ctx, projectID, projectDomain, basePath, serviceName, component.Port); err != nil {
logging.FieldProjectID, projectID, log.Warn("failed to add ingress path for component",
"component", component.Name, logging.FieldProjectID, projectID,
"path", basePath, "component", component.Name,
logging.FieldError, err, "path", basePath,
) 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)