|
| @@ -217,132 +217,165 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta |
| 217 | | }) | 217 | | }) |
| 218 | | log := zerowrap.FromCtx(ctx) | 218 | | log := zerowrap.FromCtx(ctx) |
| 219 | | | 219 | | |
| 220 | - | // Get existing container (if any) without holding lock | 220 | + | existing, hasExisting := s.getTrackedContainer(route.Domain) |
| | | 221 | + | |
| | | 222 | + | resources, err := s.prepareDeployResources(ctx, route, existing) |
| | | 223 | + | if err != nil { |
| | | 224 | + | return nil, err |
| | | 225 | + | } |
| | | 226 | + | |
| | | 227 | + | newContainer, err := s.createStartedContainer(ctx, route, existing, resources) |
| | | 228 | + | if err != nil { |
| | | 229 | + | return nil, err |
| | | 230 | + | } |
| | | 231 | + | |
| | | 232 | + | s.activateDeployedContainer(ctx, route.Domain, newContainer) |
| | | 233 | + | s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, newContainer.ID) |
| | | 234 | + | |
| | | 235 | + | // Start container log collection (non-blocking, errors don't fail deployment) |
| | | 236 | + | s.startLogCollection(ctx, newContainer.ID, route.Domain) |
| | | 237 | + | |
| | | 238 | + | log.Info(). |
| | | 239 | + | Str("image", route.Image). |
| | | 240 | + | Str(zerowrap.FieldEntityID, newContainer.ID). |
| | | 241 | + | Ints("ports", newContainer.Ports). |
| | | 242 | + | Str("network", resources.networkName). |
| | | 243 | + | Bool("zero_downtime", hasExisting). |
| | | 244 | + | Msg("container deployed successfully") |
| | | 245 | + | |
| | | 246 | + | return newContainer, nil |
| | | 247 | + | } |
| | | 248 | + | |
| | | 249 | + | type deployResources struct { |
| | | 250 | + | networkName string |
| | | 251 | + | actualImageRef string |
| | | 252 | + | exposedPorts []int |
| | | 253 | + | envVars []string |
| | | 254 | + | volumes map[string]string |
| | | 255 | + | } |
| | | 256 | + | |
| | | 257 | + | func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) { |
| 221 | | s.mu.RLock() | 258 | | s.mu.RLock() |
| 222 | - | existing, hasExisting := s.containers[route.Domain] | 259 | + | container, ok := s.containers[domainName] |
| 223 | | s.mu.RUnlock() | 260 | | s.mu.RUnlock() |
| | | 261 | + | return container, ok |
| | | 262 | + | } |
| | | 263 | + | |
| | | 264 | + | func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) { |
| | | 265 | + | log := zerowrap.FromCtx(ctx) |
| 224 | | | 266 | | |
| 225 | - | // Clean up orphaned containers (containers with same name but not tracked) | | | |
| 226 | - | // Skip the currently tracked container to preserve zero-downtime deployment | | | |
| 227 | | existingID := "" | 267 | | existingID := "" |
| 228 | - | if hasExisting { | 268 | + | if existing != nil { |
| 229 | | existingID = existing.ID | 269 | | existingID = existing.ID |
| 230 | | } | 270 | | } |
| 231 | | if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil { | 271 | | if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil { |
| 232 | | log.WrapErr(err, "failed to cleanup orphaned containers") | 272 | | log.WrapErr(err, "failed to cleanup orphaned containers") |
| 233 | | } | 273 | | } |
| 234 | | | 274 | | |
| 235 | - | // Build image reference and ensure it's available. | | | |
| 236 | - | // ensureImage returns the canonical reference; internal pulls may use the local registry. | | | |
| 237 | | imageRef := s.buildImageRef(route.Image) | 275 | | imageRef := s.buildImageRef(route.Image) |
| 238 | | actualImageRef, err := s.ensureImage(ctx, imageRef) | 276 | | actualImageRef, err := s.ensureImage(ctx, imageRef) |
| 239 | | if err != nil { | 277 | | if err != nil { |
| 240 | | return nil, err | 278 | | return nil, err |
| 241 | | } | 279 | | } |
| 242 | | | 280 | | |
| 243 | - | // Setup network FIRST (attachments need it) | | | |
| 244 | | networkName := s.getNetworkForApp(route.Domain) | 281 | | networkName := s.getNetworkForApp(route.Domain) |
| 245 | | if err := s.createNetworkIfNeeded(ctx, networkName); err != nil { | 282 | | if err := s.createNetworkIfNeeded(ctx, networkName); err != nil { |
| 246 | | return nil, log.WrapErr(err, "failed to create network") | 283 | | return nil, log.WrapErr(err, "failed to create network") |
| 247 | | } | 284 | | } |
| 248 | - | | | | |
| 249 | - | // Deploy attachments BEFORE main container (dependencies first) | | | |
| 250 | | if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil { | 285 | | if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil { |
| 251 | | return nil, log.WrapErr(err, "failed to deploy attachments") | 286 | | return nil, log.WrapErr(err, "failed to deploy attachments") |
| 252 | | } | 287 | | } |
| 253 | | | 288 | | |
| 254 | - | // Get exposed ports (use actual image ref from pull) | | | |
| 255 | | exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef) | 289 | | exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef) |
| 256 | | if err != nil { | 290 | | if err != nil { |
| 257 | | log.WrapErr(err, "failed to get exposed ports, using defaults") | 291 | | log.WrapErr(err, "failed to get exposed ports, using defaults") |
| 258 | | exposedPorts = []int{80, 8080, 3000} | 292 | | exposedPorts = []int{80, 8080, 3000} |
| 259 | | } | 293 | | } |
| 260 | | | 294 | | |
| 261 | - | // Load environment | | | |
| 262 | | envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef) | 295 | | envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef) |
| 263 | | if err != nil { | 296 | | if err != nil { |
| 264 | | return nil, err | 297 | | return nil, err |
| 265 | | } | 298 | | } |
| 266 | | | 299 | | |
| 267 | - | // Setup volumes (use actual image ref from pull) | | | |
| 268 | | volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef) | 300 | | volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef) |
| 269 | | if err != nil { | 301 | | if err != nil { |
| 270 | | return nil, err | 302 | | return nil, err |
| 271 | | } | 303 | | } |
| 272 | | | 304 | | |
| 273 | - | // Build container configuration | 305 | + | return &deployResources{ |
| 274 | - | containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, existing) | 306 | + | networkName: networkName, |
| | | 307 | + | actualImageRef: actualImageRef, |
| | | 308 | + | exposedPorts: exposedPorts, |
| | | 309 | + | envVars: envVars, |
| | | 310 | + | volumes: volumes, |
| | | 311 | + | }, nil |
| | | 312 | + | } |
| | | 313 | + | |
| | | 314 | + | func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) { |
| | | 315 | + | log := zerowrap.FromCtx(ctx) |
| | | 316 | + | |
| | | 317 | + | containerConfig := s.buildContainerConfig( |
| | | 318 | + | route.Domain, |
| | | 319 | + | route.Image, |
| | | 320 | + | resources.actualImageRef, |
| | | 321 | + | resources.exposedPorts, |
| | | 322 | + | resources.envVars, |
| | | 323 | + | resources.volumes, |
| | | 324 | + | resources.networkName, |
| | | 325 | + | existing, |
| | | 326 | + | ) |
| 275 | | | 327 | | |
| 276 | | newContainer, err := s.runtime.CreateContainer(ctx, containerConfig) | 328 | | newContainer, err := s.runtime.CreateContainer(ctx, containerConfig) |
| 277 | | if err != nil { | 329 | | if err != nil { |
| 278 | | return nil, log.WrapErr(err, "failed to create container") | 330 | | return nil, log.WrapErr(err, "failed to create container") |
| 279 | | } | 331 | | } |
| 280 | - | | | | |
| 281 | - | // Start new container | | | |
| 282 | | if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil { | 332 | | if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil { |
| 283 | | s.runtime.RemoveContainer(ctx, newContainer.ID, true) | 333 | | s.runtime.RemoveContainer(ctx, newContainer.ID, true) |
| 284 | | return nil, log.WrapErr(err, "failed to start container") | 334 | | return nil, log.WrapErr(err, "failed to start container") |
| 285 | | } | 335 | | } |
| 286 | - | | | | |
| 287 | - | // Wait for new container to be ready | | | |
| 288 | | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | 336 | | if err := s.waitForReady(ctx, newContainer.ID); err != nil { |
| 289 | | s.cleanupFailedContainer(ctx, newContainer.ID) | 337 | | s.cleanupFailedContainer(ctx, newContainer.ID) |
| 290 | | return nil, log.WrapErr(err, "container failed readiness check") | 338 | | return nil, log.WrapErr(err, "container failed readiness check") |
| 291 | | } | 339 | | } |
| 292 | | | 340 | | |
| 293 | - | // Re-inspect for updated info (ports, etc.) | 341 | + | inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID) |
| 294 | - | newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID) | | | |
| 295 | | if err != nil { | 342 | | if err != nil { |
| 296 | | return nil, log.WrapErr(err, "failed to inspect started container") | 343 | | return nil, log.WrapErr(err, "failed to inspect started container") |
| 297 | | } | 344 | | } |
| 298 | | | 345 | | |
| 299 | - | // ATOMIC SWITCH: Update tracking first (proxy will now route to new container) | 346 | + | return inspected, nil |
| | | 347 | + | } |
| | | 348 | + | |
| | | 349 | + | func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) { |
| 300 | | s.mu.Lock() | 350 | | s.mu.Lock() |
| 301 | - | s.containers[route.Domain] = newContainer | 351 | + | s.containers[domainName] = container |
| 302 | | s.mu.Unlock() | 352 | | s.mu.Unlock() |
| 303 | | | 353 | | |
| 304 | - | // Publish container deployed event (other subscribers) | 354 | + | s.publishContainerDeployed(ctx, domainName, container.ID) |
| 305 | - | s.publishContainerDeployed(ctx, route.Domain, newContainer.ID) | | | |
| 306 | | | 355 | | |
| 307 | - | // Synchronous cache invalidation: ensure the proxy stops routing to the | | | |
| 308 | - | // old container immediately, before we stop it. This fixes the race where | | | |
| 309 | - | // the async event bus invalidation arrives after the old container is killed. | | | |
| 310 | | s.mu.RLock() | 356 | | s.mu.RLock() |
| 311 | | inv := s.cacheInvalidator | 357 | | inv := s.cacheInvalidator |
| 312 | | s.mu.RUnlock() | 358 | | s.mu.RUnlock() |
| 313 | | if inv != nil { | 359 | | if inv != nil { |
| 314 | - | inv.InvalidateTarget(ctx, route.Domain) | 360 | + | inv.InvalidateTarget(ctx, domainName) |
| 315 | | } | 361 | | } |
| | | 362 | + | } |
| 316 | | | 363 | | |
| 317 | - | // Grace period: let in-flight requests to the old container finish draining. | 364 | + | func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting bool, newContainerID string) { |
| 318 | - | if hasExisting { | 365 | + | if !hasExisting { |
| 319 | - | drainDelay := s.config.DrainDelay | 366 | + | return |
| 320 | - | if drainDelay == 0 { | | | |
| 321 | - | drainDelay = 2 * time.Second | | | |
| 322 | - | } | | | |
| 323 | - | select { | | | |
| 324 | - | case <-time.After(drainDelay): | | | |
| 325 | - | case <-ctx.Done(): | | | |
| 326 | - | } | | | |
| 327 | | } | 367 | | } |
| 328 | | | 368 | | |
| 329 | - | // NOW safe to stop and remove old container | 369 | + | drainDelay := s.config.DrainDelay |
| 330 | - | if hasExisting { | 370 | + | if drainDelay == 0 { |
| 331 | - | s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain) | 371 | + | drainDelay = 2 * time.Second |
| | | 372 | + | } |
| | | 373 | + | select { |
| | | 374 | + | case <-time.After(drainDelay): |
| | | 375 | + | case <-ctx.Done(): |
| 332 | | } | 376 | | } |
| 333 | | | 377 | | |
| 334 | - | // Start container log collection (non-blocking, errors don't fail deployment) | 378 | + | s.cleanupOldContainer(ctx, existing, newContainerID, domainName) |
| 335 | - | s.startLogCollection(ctx, newContainer.ID, route.Domain) | | | |
| 336 | - | | | | |
| 337 | - | log.Info(). | | | |
| 338 | - | Str("image", route.Image). | | | |
| 339 | - | Str(zerowrap.FieldEntityID, newContainer.ID). | | | |
| 340 | - | Ints("ports", newContainer.Ports). | | | |
| 341 | - | Str("network", networkName). | | | |
| 342 | - | Bool("zero_downtime", hasExisting). | | | |
| 343 | - | Msg("container deployed successfully") | | | |
| 344 | - | | | | |
| 345 | - | return newContainer, nil | | | |
| 346 | | } | 379 | | } |
| 347 | | | 380 | | |
| 348 | | // Restart restarts a running container for the given domain. | 381 | | // Restart restarts a running container for the given domain. |