diff --git a/agent/cmd/agent/main.go b/agent/cmd/agent/main.go index 59bd84b1..51e58a6f 100644 --- a/agent/cmd/agent/main.go +++ b/agent/cmd/agent/main.go @@ -141,7 +141,7 @@ func main() { } if err = container.EnsureNetwork(config.SubnetID); err != nil { - log.Printf("Warning: Failed to ensure container network: %v", err) + log.Printf("Warning: Failed to ensure container network/forwarding: %v", err) } if !disableDNS { @@ -258,7 +258,7 @@ func main() { log.Println("Ensuring container network exists...") if err = container.EnsureNetwork(config.SubnetID); err != nil { - log.Printf("Warning: Failed to create container network: %v", err) + log.Printf("Warning: Failed to ensure container network/forwarding: %v", err) } else { log.Println("Container network ready") } @@ -337,7 +337,7 @@ func main() { publicIP := network.PublicIP() privateIP := network.PrivateIP() - log.Printf("Agent v%s started. Public IP: %s, Private IP: %s. Tick interval: %v", agent.Version, publicIP, privateIP, agent.TickInterval) + log.Printf("Agent %s started. Public IP: %s, Private IP: %s. Tick interval: %v", agent.Version, publicIP, privateIP, agent.TickInterval) agentInstance := agent.NewAgent(client, reconciler, config, publicIP, privateIP, dataDir, logCollector, traefikLogCollector, metricsSender, routeOwners, builder, config.IsProxy, disableDNS) agentInstance.Run(ctx) diff --git a/agent/internal/container/runtime.go b/agent/internal/container/runtime.go index 297b97c9..f5d320f9 100644 --- a/agent/internal/container/runtime.go +++ b/agent/internal/container/runtime.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "log" "os" @@ -13,6 +14,7 @@ import ( "time" "techulus/cloud-agent/internal/retry" + "techulus/cloud-agent/internal/wireguard" ) func ContainerExists(containerID string) (bool, error) { @@ -498,31 +500,70 @@ func EnsureNetwork(subnetId int) error { gateway := fmt.Sprintf("10.200.%d.1", subnetId) checkCmd := exec.Command("podman", "network", "inspect", NetworkName) - if err := checkCmd.Run(); err == nil { - return nil + if err := checkCmd.Run(); err != nil { + args := []string{ + "network", "create", + "--driver", "bridge", + "--subnet", subnet, + "--gateway", gateway, + "--disable-dns", + NetworkName, + } + + createCmd := exec.Command("podman", args...) + output, err := createCmd.CombinedOutput() + if err != nil && !strings.Contains(string(output), "already exists") { + return fmt.Errorf("failed to create network: %s: %w", string(output), err) + } + + if err == nil { + // Podman only creates the bridge interface when a container uses the network. + // Run a throwaway container to force bridge creation so DNS can bind to the gateway IP. + exec.Command("podman", "run", "--rm", "--network", NetworkName, "busybox", "true").Run() + } } - args := []string{ - "network", "create", - "--driver", "bridge", - "--subnet", subnet, - "--gateway", gateway, - "--disable-dns", - NetworkName, + return ensureForwarding(subnetId) +} + +func forwardingRuleArgs(subnetId int) []string { + return []string{ + "-i", wireguard.DefaultInterface, + "-d", fmt.Sprintf("10.200.%d.0/24", subnetId), + "-m", "conntrack", + "--ctstate", "NEW,RELATED,ESTABLISHED", + "-j", "ACCEPT", } +} - createCmd := exec.Command("podman", args...) - output, err := createCmd.CombinedOutput() - if err != nil { - if strings.Contains(string(output), "already exists") { - return nil - } - return fmt.Errorf("failed to create network: %s: %w", string(output), err) +func isIPTablesRuleMissing(err error) bool { + var exitErr *exec.ExitError + return errors.As(err, &exitErr) && exitErr.ExitCode() == 1 +} + +func ensureForwarding(subnetId int) error { + if exec.Command("systemctl", "is-active", "--quiet", "firewalld").Run() == nil { + return fmt.Errorf("active firewalld is not supported for WireGuard container forwarding") + } + if _, err := exec.LookPath("iptables"); err != nil { + return fmt.Errorf("iptables not found: %w", err) } - // Podman only creates the bridge interface when a container uses the network. - // Run a throwaway container to force bridge creation so DNS can bind to the gateway IP. - exec.Command("podman", "run", "--rm", "--network", NetworkName, "busybox", "true").Run() + rule := forwardingRuleArgs(subnetId) + checkArgs := append([]string{"-w", "5", "-C", "FORWARD"}, rule...) + output, err := exec.Command("iptables", checkArgs...).CombinedOutput() + if err == nil { + return nil + } + if !isIPTablesRuleMissing(err) { + return fmt.Errorf("failed to check WireGuard container forwarding: %s: %w", strings.TrimSpace(string(output)), err) + } + + insertArgs := append([]string{"-w", "5", "-I", "FORWARD", "1"}, rule...) + output, err = exec.Command("iptables", insertArgs...).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to allow WireGuard container forwarding: %s: %w", strings.TrimSpace(string(output)), err) + } return nil } diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go index 8cdaa68d..49fa336f 100644 --- a/cli/internal/api/client.go +++ b/cli/internal/api/client.go @@ -64,6 +64,10 @@ func NewClient(host, apiKey string) *Client { } func (c *Client) RequestJSON(ctx context.Context, method, path string, query url.Values, body any, out any) error { + return c.RequestJSONWithHeaders(ctx, method, path, query, nil, body, out) +} + +func (c *Client) RequestJSONWithHeaders(ctx context.Context, method, path string, query url.Values, customHeaders map[string]string, body any, out any) error { endpoint := c.Host + path if len(query) > 0 { endpoint += "?" + query.Encode() @@ -72,6 +76,9 @@ func (c *Client) RequestJSON(ctx context.Context, method, path string, query url if c.APIKey != "" { headers["x-api-key"] = c.APIKey } + for key, value := range customHeaders { + headers[key] = value + } return JSON(ctx, c.HTTPClient, method, endpoint, headers, body, out) } diff --git a/cli/internal/cli/app.go b/cli/internal/cli/app.go index 9d09e9f2..990de668 100644 --- a/cli/internal/cli/app.go +++ b/cli/internal/cli/app.go @@ -55,6 +55,14 @@ type handledError struct { err error } +type applyPlanError struct { + message string + plan applyResponse +} + +func (e applyPlanError) Error() string { return e.message } +func (e applyPlanError) PlanData() any { return e.plan } + func (e handledError) Error() string { return e.err.Error() } @@ -286,16 +294,12 @@ func (a *App) initCommand() *cobra.Command { folderName = "my-service" } starter := fmt.Sprintf(`apiVersion: v1 -project: - slug: %s -environment: - name: production service: name: %s source: type: image image: nginx:1.27 - hostname: null + hostname: %s replicas: 1 placement: mode: automatic @@ -304,6 +308,7 @@ service: ports: - containerPort: 80 public: false + resources: null `, folderName, folderName) if err := os.WriteFile(manifestPath, []byte(starter), 0o644); err != nil { return err @@ -320,29 +325,20 @@ service: } func (a *App) linkCommand() *cobra.Command { - var force bool - var projectID, environmentID, serviceID string + var serviceID string cmd := &cobra.Command{ Use: "link", Short: "Create techulus.yml from an existing service", Annotations: map[string]string{ - "agent_notes": "Requires an interactive terminal and does not support --agent or --json. Agents should usually pass --project, --environment, and --service to status/logs instead of linking.", + "agent_notes": "Requires an interactive terminal or --service and does not support --agent or --json. Agents can pass --service to service commands instead of linking.", }, RunE: func(cmd *cobra.Command, args []string) error { if a.isMachineOutput() { return errors.New("tc link does not support --agent or --json") } - explicitIDs := 0 - for _, id := range []string{projectID, environmentID, serviceID} { - if strings.TrimSpace(id) != "" { - explicitIDs++ - } - } - if explicitIDs != 0 && explicitIDs != 3 { - return errors.New("provide --project, --environment, and --service together") - } - if explicitIDs == 0 && !a.IsInteractive() { - return errors.New("tc link requires an interactive terminal or all ID flags") + explicitID := strings.TrimSpace(serviceID) != "" + if !explicitID && !a.IsInteractive() { + return errors.New("tc link requires an interactive terminal or --service") } config, err := a.requireConfig() if err != nil { @@ -353,13 +349,31 @@ func (a *App) linkCommand() *cobra.Command { return err } manifestPath := filepath.Join(cwd, "techulus.yml") - if _, err := os.Stat(manifestPath); err == nil && !force { - return errors.New("techulus.yml already exists. Run `tc link --force` to replace it") - } else if err != nil && !errors.Is(err, os.ErrNotExist) { + var existing *manifest.Loaded + if _, err := os.Stat(manifestPath); err == nil { + existing, err = manifest.Load(cwd) + if err != nil { + return err + } + } else if !errors.Is(err, os.ErrNotExist) { return err } client := a.client(config) + if explicitID { + var direct struct { + Service serviceItem `json:"service"` + Target targetContext `json:"target"` + Management *serviceManagement `json:"management"` + } + if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/services/"+url.PathEscape(serviceID), nil, nil, &direct); err != nil { + return err + } + if err := managementCompatibilityError(direct.Management); err != nil { + return err + } + return a.finishLink(manifestPath, existing, direct.Service, direct.Target) + } ps, err := fetchAllProjects(cmd.Context(), client) if err != nil { return err @@ -369,16 +383,7 @@ func (a *App) linkCommand() *cobra.Command { } reader := bufio.NewReader(a.In) var project projectItem - if projectID != "" { - for _, v := range ps.Projects { - if v.ID == projectID { - project = v - } - } - if project.ID == "" { - return errors.New("project ID not found") - } - } else { + { project, err = selectFromList(reader, a.Out, "Select a project:", ps.Projects, func(v projectItem) string { return v.Name }) if err != nil { return err @@ -390,15 +395,10 @@ func (a *App) linkCommand() *cobra.Command { return err } var environment environmentItem - if environmentID != "" { - for _, v := range es.Environments { - if v.ID == environmentID { - environment = v - } - } - if environment.ID == "" { - return errors.New("environment ID not found") - } + if len(es.Environments) == 0 { + return errors.New("selected project has no environments") + } else if len(es.Environments) == 1 { + environment = es.Environments[0] } else { environment, err = selectFromList(reader, a.Out, "Select an environment:", es.Environments, func(v environmentItem) string { return v.Name }) if err != nil { @@ -411,16 +411,7 @@ func (a *App) linkCommand() *cobra.Command { return err } var service serviceItem - if serviceID != "" { - for _, v := range ss.Services { - if v.ID == serviceID { - service = v - } - } - if service.ID == "" { - return errors.New("service ID not found") - } - } else { + { service, err = selectFromList(reader, a.Out, "Select a service:", ss.Services, func(v serviceItem) string { return v.Name }) if err != nil { return err @@ -446,26 +437,14 @@ func (a *App) linkCommand() *cobra.Command { StartCommand *string `json:"startCommand"` Resources *manifest.Resources `json:"resources"` } `json:"current"` - Management *struct { - Patchable bool `json:"patchable"` - Blockers []struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"blockers"` - } `json:"management"` - } - base := sp + "/" + url.PathEscape(service.ID) + Management *serviceManagement `json:"management"` + } + base := "/api/v1/services/" + url.PathEscape(service.ID) if err := client.RequestJSON(cmd.Context(), http.MethodGet, base+"/configuration", nil, nil, &cfg); err != nil { return err } - if cfg.Management == nil { - return errors.New("configuration response did not include service management compatibility") - } - if !cfg.Management.Patchable { - if len(cfg.Management.Blockers) > 0 && cfg.Management.Blockers[0].Message != "" { - return errors.New(cfg.Management.Blockers[0].Message) - } - return errors.New("this service cannot be managed with techulus.yml") + if err := managementCompatibilityError(cfg.Management); err != nil { + return err } if cfg.Current.Resources != nil && cfg.Current.Resources.CPUCores == nil && cfg.Current.Resources.MemoryMB == nil { cfg.Current.Resources = nil @@ -488,7 +467,16 @@ func (a *App) linkCommand() *cobra.Command { } } } - m := manifest.Manifest{APIVersion: "v1", Project: manifest.Project{ID: project.ID, Slug: project.Slug}, Environment: manifest.Environment{ID: environment.ID, Name: environment.Name}, Service: manifest.Service{ID: service.ID, Name: service.Name, Source: service.Source, Hostname: cfg.Current.Hostname, Ports: ports, Replicas: cfg.Current.Replicas, Placement: placement, HealthCheck: cfg.Current.HealthCheck, StartCommand: cfg.Current.StartCommand, Resources: cfg.Current.Resources}} + m := manifest.Manifest{APIVersion: "v1", Target: &manifest.Target{ServiceID: service.ID}, Service: manifest.Service{Name: service.Name, Source: service.Source, Hostname: cfg.Current.Hostname, Ports: ports, Replicas: cfg.Current.Replicas, Placement: placement, HealthCheck: cfg.Current.HealthCheck, StartCommand: cfg.Current.StartCommand, Resources: cfg.Current.Resources}} + if existing != nil { + if existing.Manifest.Linked() && existing.Manifest.Target.ServiceID != service.ID { + return fmt.Errorf("manifest is linked to service %s; remove target.serviceId before relinking", existing.Manifest.Target.ServiceID) + } + m.Service = existing.Manifest.Service + if m.Service.Hostname == nil { + m.Service.Hostname = cfg.Current.Hostname + } + } if err := manifest.Save(manifestPath, m); err != nil { return err } @@ -499,15 +487,13 @@ func (a *App) linkCommand() *cobra.Command { return nil }, } - cmd.Flags().BoolVar(&force, "force", false, "Replace an existing techulus.yml") - cmd.Flags().StringVar(&projectID, "project", "", "Project ID") - cmd.Flags().StringVar(&environmentID, "environment", "", "Environment ID") cmd.Flags().StringVar(&serviceID, "service", "", "Service ID") return cmd } func (a *App) applyCommand() *cobra.Command { - return &cobra.Command{ + var yes bool + cmd := &cobra.Command{ Use: "apply", Short: "Apply techulus.yml to the linked service", Annotations: map[string]string{ @@ -522,7 +508,6 @@ func (a *App) applyCommand() *cobra.Command { if err != nil { return err } - var result applyResponse client := a.client(config) if !loaded.Manifest.Linked() { return errors.New("service is not linked: run `tc link`") @@ -531,25 +516,139 @@ func (a *App) applyCommand() *cobra.Command { if placement == nil { return errors.New("service.placement is required") } - body := map[string]any{"source": sourcePatch(loaded.Manifest.Service.Source), "hostname": loaded.Manifest.Service.Hostname, "ports": loaded.Manifest.Service.Ports, "healthCheck": loaded.Manifest.Service.HealthCheck, "startCommand": loaded.Manifest.Service.StartCommand} + body := map[string]any{"name": loaded.Manifest.Service.Name, "source": sourcePatch(loaded.Manifest.Service.Source), "hostname": loaded.Manifest.Service.Hostname, "ports": loaded.Manifest.Service.Ports, "healthCheck": loaded.Manifest.Service.HealthCheck, "startCommand": loaded.Manifest.Service.StartCommand, "resources": loaded.Manifest.Service.Resources} if placement.Mode == "automatic" { body["placement"] = map[string]any{"mode": "automatic", "replicas": loaded.Manifest.Service.Replicas} } else { body["placement"] = map[string]any{"mode": "manual", "placements": placement.Servers} } - if loaded.Manifest.Service.Resources != nil { - body["resources"] = loaded.Manifest.Service.Resources + base, err := serviceBase(loaded.Manifest) + if err != nil { + return err } - if err := client.RequestJSON(cmd.Context(), http.MethodPatch, serviceBase(loaded.Manifest)+"/configuration", nil, body, &result); err != nil { + base += "/configuration" + var plan applyResponse + if err := client.RequestJSON(cmd.Context(), http.MethodPost, base+"/plan", nil, body, &plan); err != nil { return err } - if a.isMachineOutput() { - return a.writeData(result, "Apply") + confirmationReader := bufio.NewReader(a.In) + const maxStaleReplans = 3 + staleReplans := 0 + for { + if !a.isMachineOutput() { + printApplyResult(a.Out, "Plan", plan) + } + if len(plan.Changes) == 0 { + if a.isMachineOutput() { + return a.writeData(plan, "Plan") + } + fmt.Fprintln(a.Out, "No changes. The service already matches techulus.yml.") + return nil + } + if staleReplans >= maxStaleReplans { + return applyPlanError{ + message: "service configuration keeps changing; review the latest plan and try again", + plan: plan, + } + } + if yes && staleReplans > 0 { + return applyPlanError{ + message: "configuration changed; review the new plan and run tc apply --yes again", + plan: plan, + } + } + if !yes { + if !a.IsInteractive() { + return applyPlanError{message: "confirmation required", plan: plan} + } + confirmed, confirmErr := a.confirmApply(confirmationReader) + if confirmErr != nil || !confirmed { + return confirmErr + } + } + + var result applyResponse + err = client.RequestJSONWithHeaders(cmd.Context(), http.MethodPut, base, nil, map[string]string{"If-Match": fmt.Sprintf("%q", plan.CurrentVersion)}, body, &result) + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.Code == "CONFIGURATION_PLAN_STALE" { + if planErr := client.RequestJSON(cmd.Context(), http.MethodPost, base+"/plan", nil, body, &plan); planErr != nil { + return planErr + } + staleReplans++ + continue + } + if err != nil { + return err + } + if a.isMachineOutput() { + return a.writeData(result, "Applied") + } + printApplyResult(a.Out, "Applied", result) + return nil } - printApplyResult(a.Out, result) - return nil }, } + cmd.Flags().BoolVar(&yes, "yes", false, "Apply the displayed plan without prompting") + return cmd +} + +func (a *App) confirmApply(reader *bufio.Reader) (bool, error) { + fmt.Fprint(a.Out, "Apply these changes? [y/N] ") + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, err + } + answer := strings.ToLower(strings.TrimSpace(line)) + return answer == "y" || answer == "yes", nil +} + +func managementCompatibilityError(management *serviceManagement) error { + if management == nil { + return errors.New("service response did not include management compatibility") + } + if management.Patchable { + return nil + } + if len(management.Blockers) > 0 && management.Blockers[0].Message != "" { + return errors.New(management.Blockers[0].Message) + } + return errors.New("this service cannot be managed with techulus.yml") +} + +func (a *App) finishLink(path string, existing *manifest.Loaded, service serviceItem, target targetContext) error { + serviceID := service.ID + if target.Service.ID != "" { + serviceID = target.Service.ID + } + if existing != nil { + if existing.Manifest.Linked() { + if existing.Manifest.Target.ServiceID == serviceID { + return a.printLinked(path, target) + } + return fmt.Errorf("manifest is linked to service %s; remove target.serviceId before relinking", existing.Manifest.Target.ServiceID) + } + existing.Manifest.Target = &manifest.Target{ServiceID: serviceID} + if existing.Manifest.Service.Hostname == nil { + existing.Manifest.Service.Hostname = service.Hostname + } + if err := manifest.Save(path, existing.Manifest); err != nil { + return err + } + return a.printLinked(path, target) + } + m := manifest.Manifest{APIVersion: "v1", Target: &manifest.Target{ServiceID: serviceID}, Service: manifest.Service{Name: service.Name, Source: service.Source, Hostname: service.Hostname, Ports: service.Ports, Replicas: service.Replicas, Placement: service.Placement, HealthCheck: service.HealthCheck, StartCommand: service.StartCommand, Resources: service.Resources}} + if err := manifest.Save(path, m); err != nil { + return err + } + return a.printLinked(path, target) +} + +func (a *App) printLinked(path string, target targetContext) error { + output.Section(a.Out, "Linked") + output.Field(a.Out, "Service", fmt.Sprintf("%s/%s/%s", target.Project.Slug, target.Environment.Name, target.Service.Name)) + output.Field(a.Out, "Manifest", path) + output.Next(a.Out, "tc status or tc apply") + return nil } func (a *App) deployCommand() *cobra.Command { @@ -573,18 +672,22 @@ func (a *App) deployCommand() *cobra.Command { if !loaded.Manifest.Linked() { return errors.New("service is not linked: run `tc link`") } + base, err := serviceBase(loaded.Manifest) + if err != nil { + return err + } var persisted struct { Current struct { Source manifest.Source `json:"source"` } `json:"current"` } - if err := client.RequestJSON(cmd.Context(), http.MethodGet, serviceBase(loaded.Manifest)+"/configuration", nil, nil, &persisted); err != nil { + if err := client.RequestJSON(cmd.Context(), http.MethodGet, base+"/configuration", nil, nil, &persisted); err != nil { return err } if !sourcesEqual(loaded.Manifest.Service.Source, persisted.Current.Source) { return errors.New("service source differs from techulus.yml: run `tc apply` before deploying") } - if err := client.RequestJSON(cmd.Context(), http.MethodPost, serviceBase(loaded.Manifest)+"/deploy", nil, nil, &result); err != nil { + if err := client.RequestJSON(cmd.Context(), http.MethodPost, base+"/deploy", nil, nil, &result); err != nil { return err } if a.isMachineOutput() { @@ -611,7 +714,7 @@ func (a *App) statusCommand() *cobra.Command { Use: "status", Short: "Show service rollout and deployment status", Annotations: map[string]string{ - "agent_notes": "Without explicit target flags, tc reads techulus.yml from the current directory.\nFor agent use outside a linked directory, pass --project, --environment, and --service together.", + "agent_notes": "Without --service, tc reads the target from techulus.yml in the current directory.\nFor agent use outside a linked directory, pass --service.", }, RunE: func(cmd *cobra.Command, args []string) error { config, err := a.requireConfig() @@ -622,9 +725,13 @@ func (a *App) statusCommand() *cobra.Command { if err != nil { return err } + base, err := serviceBase(value) + if err != nil { + return err + } var status statusResponse client := a.client(config) - if err := client.RequestJSON(cmd.Context(), http.MethodGet, serviceBase(value)+"/status", nil, nil, &status); err != nil { + if err := client.RequestJSON(cmd.Context(), http.MethodGet, base+"/status", nil, nil, &status); err != nil { return err } if a.isMachineOutput() { @@ -647,7 +754,7 @@ func (a *App) logsCommand() *cobra.Command { Use: "logs", Short: "Show service logs", Annotations: map[string]string{ - "agent_notes": "Without explicit target flags, tc reads techulus.yml from the current directory.\nFor agent use outside a linked directory, pass --project, --environment, and --service together.\nIn --agent or --json mode, logs are one-shot JSON output; --follow=true is not supported.", + "agent_notes": "Without --service, tc reads the target from techulus.yml in the current directory.\nFor agent use outside a linked directory, pass --service.\nIn --agent or --json mode, logs are one-shot JSON output; --follow=true is not supported.", }, RunE: func(cmd *cobra.Command, args []string) error { if tail < 1 || tail > 1000 { @@ -715,12 +822,7 @@ func (a *App) environmentsCommand() *cobra.Command { return e } if id == "" { - if l, x := a.ensureManifest(); x == nil { - id = l.Manifest.Project.ID - } - } - if id == "" { - return errors.New("missing --project (or link this directory)") + return errors.New("missing --project") } out, e := fetchAllEnvironments(cmd.Context(), a.client(cfg), "/api/v1/projects/"+url.PathEscape(id)+"/environments") if e != nil { @@ -746,17 +848,7 @@ func (a *App) servicesCommand() *cobra.Command { return e } if p == "" || eid == "" { - if l, x := a.ensureManifest(); x == nil { - if p == "" { - p = l.Manifest.Project.ID - } - if eid == "" { - eid = l.Manifest.Environment.ID - } - } - } - if p == "" || eid == "" { - return errors.New("missing --project and --environment (or link this directory)") + return errors.New("missing --project and --environment") } path := "/api/v1/projects/" + url.PathEscape(p) + "/environments/" + url.PathEscape(eid) + "/services" out, e := fetchAllServices(cmd.Context(), a.client(cfg), path) @@ -787,12 +879,16 @@ func (a *App) resourceCommand(name, short, suffix string, q func(*cobra.Command) if e != nil { return e } + base, e := serviceBase(m) + if e != nil { + return e + } query := url.Values{} if q != nil { query = q(cmd) } var out map[string]any - if e = a.client(cfg).RequestJSON(cmd.Context(), http.MethodGet, serviceBase(m)+suffix, query, nil, &out); e != nil { + if e = a.client(cfg).RequestJSON(cmd.Context(), http.MethodGet, base+suffix, query, nil, &out); e != nil { return e } label := strings.ToUpper(name[:1]) + name[1:] @@ -833,8 +929,6 @@ func (a *App) rolloutCommand() *cobra.Command { c := &cobra.Command{Use: "rollout ", Short: "Show rollout detail", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return a.getRolloutResource(cmd, target, args[0], false, "", 100) }} - c.PersistentFlags().StringVar(&target.Project, "project", "", "Project ID") - c.PersistentFlags().StringVar(&target.Environment, "environment", "", "Environment ID") c.PersistentFlags().StringVar(&target.Service, "service", "", "Service ID") var q string var limit int @@ -858,6 +952,10 @@ func (a *App) getRolloutResource(cmd *cobra.Command, t serviceTargetFlags, id st if e != nil { return e } + base, e := serviceBase(m) + if e != nil { + return e + } suffix := "/rollouts/" + url.PathEscape(id) query := url.Values{} if logs { @@ -868,7 +966,7 @@ func (a *App) getRolloutResource(cmd *cobra.Command, t serviceTargetFlags, id st } } var out map[string]any - if e = a.client(cfg).RequestJSON(cmd.Context(), http.MethodGet, serviceBase(m)+suffix, query, nil, &out); e != nil { + if e = a.client(cfg).RequestJSON(cmd.Context(), http.MethodGet, base+suffix, query, nil, &out); e != nil { return e } if a.isMachineOutput() { @@ -1083,28 +1181,16 @@ func parseAgentArgs(cmd *cobra.Command) []agentArg { } type serviceTargetFlags struct { - Project string - Environment string - Service string + Service string } func addServiceTargetFlags(cmd *cobra.Command, target *serviceTargetFlags) { - cmd.Flags().StringVar(&target.Project, "project", "", "Project ID") - cmd.Flags().StringVar(&target.Environment, "environment", "", "Environment ID") cmd.Flags().StringVar(&target.Service, "service", "", "Service ID") } func (a *App) resolveServiceTarget(target serviceTargetFlags) (manifest.Manifest, error) { - project := strings.TrimSpace(target.Project) - environment := strings.TrimSpace(target.Environment) service := strings.TrimSpace(target.Service) - explicitCount := 0 - for _, value := range []string{project, environment, service} { - if value != "" { - explicitCount++ - } - } - if explicitCount == 0 { + if service == "" { loaded, err := a.ensureManifest() if err != nil { return manifest.Manifest{}, err @@ -1114,21 +1200,16 @@ func (a *App) resolveServiceTarget(target serviceTargetFlags) (manifest.Manifest } return loaded.Manifest, nil } - if explicitCount != 3 { - return manifest.Manifest{}, errors.New("provide --project, --environment, and --service together") - } return manifest.Manifest{ - APIVersion: "v1", - Project: manifest.Project{ID: project, Slug: project}, - Environment: manifest.Environment{ID: environment, Name: environment}, - Service: manifest.Service{ - ID: service, Name: service, - }, + APIVersion: "v1", Target: &manifest.Target{ServiceID: service}, }, nil } -func serviceBase(value manifest.Manifest) string { - return "/api/v1/projects/" + url.PathEscape(value.Project.ID) + "/environments/" + url.PathEscape(value.Environment.ID) + "/services/" + url.PathEscape(value.Service.ID) +func serviceBase(value manifest.Manifest) (string, error) { + if value.Target == nil || strings.TrimSpace(value.Target.ServiceID) == "" { + return "", errors.New("service is not linked: run `tc link`") + } + return "/api/v1/services/" + url.PathEscape(value.Target.ServiceID), nil } func sourcePatch(source manifest.Source) map[string]any { @@ -1362,7 +1443,7 @@ func (a *App) runLogs(ctx context.Context, config *auth.Config, value manifest.M if a.isMachineOutput() { return a.writeData(result, "Logs") } - fmt.Fprintf(a.Out, "%s/%s/%s\n", value.Project.Slug, value.Environment.Name, value.Service.Name) + fmt.Fprintf(a.Out, "%s/%s/%s\n", result.Target.Project.Slug, result.Target.Environment.Name, result.Target.Service.Name) if result.Provider == "disabled" { output.Section(a.Out, "Logs") output.Field(a.Out, "Status", "disabled") @@ -1439,6 +1520,10 @@ func (a *App) sleep(ctx context.Context, duration time.Duration) error { } func fetchLogs(ctx context.Context, client *api.Client, value manifest.Manifest, tail int, cursor, search, logRange string) (logsResponse, error) { + base, err := serviceBase(value) + if err != nil { + return logsResponse{}, err + } query := url.Values{} query.Set("tail", strconv.Itoa(tail)) if search != "" { @@ -1452,12 +1537,15 @@ func fetchLogs(ctx context.Context, client *api.Client, value manifest.Manifest, query.Set("wait", "20") } var result logsResponse - err := client.RequestJSON(ctx, http.MethodGet, serviceBase(value)+"/logs", query, nil, &result) + err = client.RequestJSON(ctx, http.MethodGet, base+"/logs", query, nil, &result) return result, err } -func printApplyResult(w io.Writer, result applyResponse) { - output.Section(w, "Apply") +func printApplyResult(w io.Writer, title string, result applyResponse) { + output.Section(w, title) + if result.Target.Service.ID != "" { + output.Field(w, "Target", fmt.Sprintf("%s/%s/%s", result.Target.Project.Slug, result.Target.Environment.Name, result.Target.Service.Name)) + } output.Field(w, "Action", result.Action) if len(result.Changes) == 0 { output.Field(w, "Changes", "none") @@ -1465,12 +1553,40 @@ func printApplyResult(w io.Writer, result applyResponse) { } output.Section(w, fmt.Sprintf("Changes (%d)", len(result.Changes))) for _, change := range result.Changes { - fmt.Fprintf(w, " * %s\n", change) + fmt.Fprintf(w, " * %s: %s -> %s\n", change.Field, formatApplyValue(change.From), formatApplyValue(change.To)) + } +} + +func formatApplyValue(value any) string { + switch typed := value.(type) { + case nil: + return "null" + case string: + return typed + case []any: + items := make([]string, 0, len(typed)) + for _, item := range typed { + items = append(items, formatApplyValue(item)) + } + return "[" + strings.Join(items, ", ") + "]" + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + slices.Sort(keys) + fields := make([]string, 0, len(keys)) + for _, key := range keys { + fields = append(fields, key+"="+formatApplyValue(typed[key])) + } + return "(" + strings.Join(fields, ", ") + ")" + default: + return fmt.Sprint(value) } } func printStatus(w io.Writer, value manifest.Manifest, status statusResponse) { - fmt.Fprintf(w, "%s/%s/%s\n", value.Project.Slug, value.Environment.Name, value.Service.Name) + fmt.Fprintf(w, "%s/%s/%s\n", status.Target.Project.Slug, status.Target.Environment.Name, status.Target.Service.Name) output.Section(w, "Service") output.Field(w, "ID", status.Service.ID) if status.Service.Source.Type == "image" { diff --git a/cli/internal/cli/app_test.go b/cli/internal/cli/app_test.go index b2201f0e..81cd36d2 100644 --- a/cli/internal/cli/app_test.go +++ b/cli/internal/cli/app_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -16,18 +17,17 @@ import ( "techulus/cloud-cli/internal/api" "techulus/cloud-cli/internal/auth" "techulus/cloud-cli/internal/manifest" + "techulus/cloud-cli/internal/output" ) const imageManifest = `apiVersion: v1 -project: {id: p, slug: app} -environment: {id: e, name: prod} +target: {serviceId: s} service: - id: s name: web source: {type: image, image: nginx:1.27} replicas: 2 placement: {mode: automatic} - hostname: null + hostname: web healthCheck: null startCommand: null ports: [] @@ -134,6 +134,108 @@ func TestInitRecommendsLinkInHumanAndJSON(t *testing.T) { } } +func TestInitCreatesConcreteValidHostname(t *testing.T) { + parent := t.TempDir() + d := filepath.Join(parent, "My API "+strings.Repeat("long-", 15)) + if err := os.Mkdir(d, 0o755); err != nil { + t.Fatal(err) + } + app, _ := testApp(t, d, nil) + if err := execute(app, "init"); err != nil { + t.Fatal(err) + } + loaded, err := manifest.Load(d) + if err != nil { + t.Fatal(err) + } + if loaded.Manifest.Service.Hostname == nil || len(*loaded.Manifest.Service.Hostname) > 63 || !strings.HasPrefix(*loaded.Manifest.Service.Hostname, "my-api-long") { + t.Fatalf("hostname = %#v", loaded.Manifest.Service.Hostname) + } +} + +func TestDirectLinkPreservesDesiredConfigurationAndRejectsRebind(t *testing.T) { + d := t.TempDir() + var requested string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requested = r.URL.Path + id := strings.TrimPrefix(r.URL.Path, "/api/v1/services/") + fmt.Fprintf(w, `{"target":{"project":{"id":"p","slug":"app"},"environment":{"id":"e","name":"prod"},"service":{"id":%q,"name":"remote"}},"service":{"id":%q,"name":"remote","source":{"type":"image","image":"remote:latest"},"ports":[],"replicas":1,"placement":{"mode":"automatic"},"hostname":null,"healthCheck":null,"startCommand":null,"resources":null},"management":{"patchable":true,"blockers":[]}}`, id, id) + })) + defer s.Close() + writeConfig(t, s.URL) + app, out := testApp(t, d, s.Client()) + if err := execute(app, "init"); err != nil { + t.Fatal(err) + } + before, err := manifest.Load(d) + if err != nil { + t.Fatal(err) + } + out.Reset() + if err := execute(app, "link", "--service", "s"); err != nil { + t.Fatal(err) + } + linked, _ := manifest.Load(d) + if requested != "/api/v1/services/s" || linked.Manifest.Target.ServiceID != "s" || !reflect.DeepEqual(linked.Manifest.Service, before.Manifest.Service) { + t.Fatalf("requested=%q manifest=%#v", requested, linked.Manifest) + } + assertHumanOutput(t, out.String(), "Linked", "app/prod/remote", filepath.Join(d, "techulus.yml")) + if err := execute(app, "link", "--service", "s"); err != nil { + t.Fatalf("same-target link: %v", err) + } + beforeMismatch, _ := os.ReadFile(filepath.Join(d, "techulus.yml")) + if err := execute(app, "link", "--service", "other"); err == nil || !strings.Contains(err.Error(), "remove target.serviceId") || !strings.Contains(err.Error(), "s") { + t.Fatalf("different-target error = %v", err) + } + afterMismatch, _ := os.ReadFile(filepath.Join(d, "techulus.yml")) + if !bytes.Equal(beforeMismatch, afterMismatch) { + t.Fatal("target mismatch changed manifest bytes") + } + if err := execute(app, "link", "--service", "other", "--force"); err == nil || !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("removed --force flag error = %v", err) + } +} + +func TestDirectLinkMaterializesEffectiveHostname(t *testing.T) { + d := t.TempDir() + s := responseServer(t, `{"target":{"project":{"id":"p","slug":"app"},"environment":{"id":"e","name":"prod"},"service":{"id":"s","name":"Remote Service"}},"service":{"id":"s","name":"Remote Service","source":{"type":"image","image":"nginx"},"ports":[],"replicas":1,"placement":{"mode":"automatic"},"hostname":"remote-service","healthCheck":null,"startCommand":null,"resources":null},"management":{"patchable":true,"blockers":[]}}`) + writeConfig(t, s.URL) + app, _ := testApp(t, d, s.Client()) + if err := execute(app, "link", "--service", "s"); err != nil { + t.Fatal(err) + } + loaded, err := manifest.Load(d) + if err != nil { + t.Fatal(err) + } + if loaded.Manifest.Service.Hostname == nil || *loaded.Manifest.Service.Hostname != "remote-service" { + t.Fatalf("hostname = %#v", loaded.Manifest.Service.Hostname) + } +} + +func TestDirectLinkRejectsUnmanagedServiceWithoutChangingManifest(t *testing.T) { + d := t.TempDir() + writeManifest(t, d, imageManifest) + before, err := os.ReadFile(filepath.Join(d, "techulus.yml")) + if err != nil { + t.Fatal(err) + } + s := responseServer(t, `{"target":{"project":{"id":"p","slug":"app"},"environment":{"id":"e","name":"prod"},"service":{"id":"other","name":"remote"}},"service":{"id":"other","name":"remote","source":{"type":"image","image":"remote:latest"},"ports":[],"replicas":1,"placement":{"mode":"automatic"},"hostname":null,"healthCheck":null,"startCommand":null,"resources":null},"management":{"patchable":false,"blockers":[{"code":"UNSUPPORTED_PORTS","message":"TCP services must be managed in the web UI"}]}}`) + writeConfig(t, s.URL) + app, _ := testApp(t, d, s.Client()) + err = execute(app, "link", "--service", "other") + if err == nil || !strings.Contains(err.Error(), "TCP services") { + t.Fatalf("error = %v", err) + } + after, readErr := os.ReadFile(filepath.Join(d, "techulus.yml")) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(after, before) { + t.Fatal("failed direct link changed the existing manifest") + } +} + func TestLinkByIDsFetchesConfigurationAndSupportsPublicGitHub(t *testing.T) { var paths []string s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -145,8 +247,8 @@ func TestLinkByIDsFetchesConfigurationAndSupportsPublicGitHub(t *testing.T) { w.Write([]byte(`{"environments":[{"id":"e","name":"prod"}]}`)) case "/api/v1/projects/p/environments/e/services": w.Write([]byte(`{"services":[{"id":"s","name":"web","source":{"type":"github","repository":"https://github.com/acme/public","branch":"main","rootDir":"cmd/api"}}]}`)) - case "/api/v1/projects/p/environments/e/services/s/configuration": - w.Write([]byte(`{"current":{"replicas":2,"placement":{"mode":"manual"},"placements":[{"serverId":"server-a","count":1},{"serverId":"server-b","count":1}],"hostname":null,"ports":[],"healthCheck":null,"startCommand":null},"management":{"patchable":true,"blockers":[]}}`)) + case "/api/v1/services/s/configuration": + w.Write([]byte(`{"current":{"replicas":2,"placement":{"mode":"manual"},"placements":[{"serverId":"server-a","count":1},{"serverId":"server-b","count":1}],"hostname":"web","ports":[],"healthCheck":null,"startCommand":null},"management":{"patchable":true,"blockers":[]}}`)) default: t.Errorf("path=%s", r.URL.Path) } @@ -154,10 +256,15 @@ func TestLinkByIDsFetchesConfigurationAndSupportsPublicGitHub(t *testing.T) { defer s.Close() writeConfig(t, s.URL) d := t.TempDir() - app, _ := testApp(t, d, s.Client()) - if err := execute(app, "link", "--project", "p", "--environment", "e", "--service", "s"); err != nil { + app, out := testApp(t, d, s.Client()) + app.IsInteractive = func() bool { return true } + app.In = strings.NewReader("1\n1\n") + if err := execute(app, "link"); err != nil { t.Fatal(err) } + if strings.Contains(out.String(), "Select an environment:") { + t.Fatalf("single environment unexpectedly prompted:\n%s", out.String()) + } loaded, err := manifest.Load(d) if err != nil { t.Fatal(err) @@ -168,7 +275,7 @@ func TestLinkByIDsFetchesConfigurationAndSupportsPublicGitHub(t *testing.T) { if loaded.Manifest.Service.Placement == nil || loaded.Manifest.Service.Placement.Mode != "manual" || len(loaded.Manifest.Service.Placement.Servers) != 2 || loaded.Manifest.Service.Placement.Servers[1].ServerID != "server-b" { t.Fatalf("placement=%#v", loaded.Manifest.Service.Placement) } - want := []string{"/api/v1/projects", "/api/v1/projects/p/environments", "/api/v1/projects/p/environments/e/services", "/api/v1/projects/p/environments/e/services/s/configuration"} + want := []string{"/api/v1/projects", "/api/v1/projects/p/environments", "/api/v1/projects/p/environments/e/services", "/api/v1/services/s/configuration"} if !reflect.DeepEqual(paths, want) { t.Fatalf("paths=%v", paths) } @@ -183,7 +290,7 @@ func TestLinkRejectsManualServiceWithoutPlacements(t *testing.T) { w.Write([]byte(`{"environments":[{"id":"e","name":"prod"}]}`)) case "/api/v1/projects/p/environments/e/services": w.Write([]byte(`{"services":[{"id":"s","name":"web","source":{"type":"image","image":"nginx"}}]}`)) - case "/api/v1/projects/p/environments/e/services/s/configuration": + case "/api/v1/services/s/configuration": w.Write([]byte(`{"current":{"replicas":0,"placement":{"mode":"manual"},"placements":[],"hostname":null,"ports":[],"healthCheck":null,"startCommand":null},"management":{"patchable":true,"blockers":[]}}`)) default: t.Errorf("path=%s", r.URL.Path) @@ -193,7 +300,9 @@ func TestLinkRejectsManualServiceWithoutPlacements(t *testing.T) { writeConfig(t, s.URL) d := t.TempDir() app, _ := testApp(t, d, s.Client()) - err := execute(app, "link", "--project", "p", "--environment", "e", "--service", "s") + app.IsInteractive = func() bool { return true } + app.In = strings.NewReader("1\n1\n") + err := execute(app, "link") if err == nil || !strings.Contains(err.Error(), "configure at least one server placement") { t.Fatalf("error = %v", err) } @@ -213,20 +322,20 @@ func TestApplyExactNestedPatchForSources(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { method, path = r.Method, r.URL.Path json.NewDecoder(r.Body).Decode(&body) - w.Write([]byte(`{"action":"updated","changes":["source"]}`)) + w.Write([]byte(`{"target":{"project":{"slug":"app"},"environment":{"name":"prod"},"service":{"id":"s","name":"web"}},"action":"updated","currentVersion":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","desiredVersion":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","changes":[{"field":"source","from":"old","to":"new"}]}`)) })) defer s.Close() writeConfig(t, s.URL) app, _ := testApp(t, d, s.Client()) - if err := execute(app, "apply"); err != nil { + if err := execute(app, "apply", "--yes"); err != nil { t.Fatal(err) } - if method != "PATCH" || path != "/api/v1/projects/p/environments/e/services/s/configuration" { + if method != "PUT" || path != "/api/v1/services/s/configuration" { t.Fatalf("%s %s", method, path) } source := body["source"].(map[string]any) placement := body["placement"].(map[string]any) - if source["type"] != tc.sourceType || placement["mode"] != "automatic" || placement["replicas"] != float64(2) || len(body) != 6 { + if source["type"] != tc.sourceType || placement["mode"] != "automatic" || placement["replicas"] != float64(2) || len(body) != 8 { t.Fatalf("body=%#v", body) } if tc.name == "github_clear_root" { @@ -257,12 +366,12 @@ func TestApplyPlacementPayloads(t *testing.T) { var body map[string]any s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&body) - w.Write([]byte(`{"action":"updated"}`)) + w.Write([]byte(`{"action":"updated","currentVersion":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","changes":[{"field":"placement","from":"old","to":"new"}]}`)) })) defer s.Close() writeConfig(t, s.URL) app, _ := testApp(t, d, s.Client()) - if err := execute(app, "apply"); err != nil { + if err := execute(app, "apply", "--yes"); err != nil { t.Fatal(err) } if _, exists := body["replicas"]; exists { @@ -275,6 +384,319 @@ func TestApplyPlacementPayloads(t *testing.T) { } } +func TestApplyPlansAndRequiresConfirmation(t *testing.T) { + const firstVersion = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + planWithChanges := fmt.Sprintf(`{"target":{"project":{"id":"p","slug":"app"},"environment":{"id":"e","name":"prod"},"service":{"id":"s","name":"web"}},"action":"updated","currentVersion":%q,"desiredVersion":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","changes":[{"field":"name","from":"old-web","to":"web"},{"field":"source.branch","from":"develop","to":"main"}]}`, firstVersion) + appliedWithChanges := fmt.Sprintf(`{"target":{"project":{"id":"p","slug":"app"},"environment":{"id":"e","name":"prod"},"service":{"id":"s","name":"web"}},"action":"updated","currentVersion":%q,"desiredVersion":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","changes":[{"field":"name","from":"applied-old-web","to":"web"}]}`, firstVersion) + noChanges := fmt.Sprintf(`{"target":{"project":{"id":"p","slug":"app"},"environment":{"id":"e","name":"prod"},"service":{"id":"s","name":"web"}},"action":"noop","currentVersion":%q,"desiredVersion":%q,"changes":[]}`, firstVersion, firstVersion) + + t.Run("no changes skips prompt and write", func(t *testing.T) { + requests := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/services/s/configuration/plan" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + w.Write([]byte(noChanges)) + })) + defer s.Close() + writeConfig(t, s.URL) + d := t.TempDir() + writeManifest(t, d, imageManifest) + app, out := testApp(t, d, s.Client()) + if err := execute(app, "apply"); err != nil { + t.Fatal(err) + } + if requests != 1 || !strings.Contains(out.String(), "No changes. The service already matches techulus.yml.") || strings.Contains(out.String(), "Apply these changes?") { + t.Fatalf("requests=%d output=%q", requests, out.String()) + } + }) + + t.Run("noninteractive displays every change and fails", func(t *testing.T) { + requests := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.Write([]byte(planWithChanges)) + })) + defer s.Close() + writeConfig(t, s.URL) + d := t.TempDir() + writeManifest(t, d, imageManifest) + app, out := testApp(t, d, s.Client()) + err := execute(app, "apply") + if err == nil || !strings.Contains(err.Error(), "confirmation required") || requests != 1 { + t.Fatalf("error=%v requests=%d", err, requests) + } + assertHumanOutput(t, out.String(), "app/prod/web", "name: old-web -> web", "source.branch: develop -> main") + }) + + t.Run("decline does not write", func(t *testing.T) { + requests := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.Write([]byte(planWithChanges)) + })) + defer s.Close() + writeConfig(t, s.URL) + d := t.TempDir() + writeManifest(t, d, imageManifest) + app, out := testApp(t, d, s.Client()) + app.IsInteractive = func() bool { return true } + app.In = strings.NewReader("no\n") + if err := execute(app, "apply"); err != nil { + t.Fatal(err) + } + if requests != 1 || !strings.Contains(out.String(), "Apply these changes? [y/N]") { + t.Fatalf("requests=%d output=%q", requests, out.String()) + } + }) + + t.Run("yes applies the planned version", func(t *testing.T) { + var requests []string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + switch len(requests) { + case 1: + w.Write([]byte(planWithChanges)) + case 2: + if got := r.Header.Get("If-Match"); got != `"`+firstVersion+`"` { + t.Errorf("If-Match = %q", got) + } + w.Write([]byte(appliedWithChanges)) + default: + t.Errorf("unexpected request %d", len(requests)) + } + })) + defer s.Close() + writeConfig(t, s.URL) + d := t.TempDir() + writeManifest(t, d, imageManifest) + app, out := testApp(t, d, s.Client()) + app.IsInteractive = func() bool { return true } + app.In = strings.NewReader("yes\n") + if err := execute(app, "apply"); err != nil { + t.Fatal(err) + } + want := []string{"POST /api/v1/services/s/configuration/plan", "PUT /api/v1/services/s/configuration"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%v", requests) + } + assertHumanOutput(t, out.String(), "Plan", "Applied", "name: applied-old-web -> web") + }) +} + +func TestApplyStalePlanBehavior(t *testing.T) { + const firstVersion = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const secondVersion = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + plan := func(version, from string) string { + return fmt.Sprintf(`{"target":{"project":{"slug":"app"},"environment":{"name":"prod"},"service":{"id":"s","name":"web"}},"action":"updated","currentVersion":%q,"changes":[{"field":"source.branch","from":%q,"to":"main"}]}`, version, from) + } + + for _, tc := range []struct { + name string + args []string + input string + wantCalls int + wantSecond bool + }{ + {name: "interactive replans and reconfirms", args: []string{"apply"}, input: "y\ny\n", wantCalls: 4, wantSecond: true}, + {name: "yes replans but does not silently write", args: []string{"apply", "--yes"}, wantCalls: 3}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + switch calls { + case 1: + w.Write([]byte(plan(firstVersion, "develop"))) + case 2: + w.WriteHeader(http.StatusConflict) + w.Write([]byte(`{"code":"CONFIGURATION_PLAN_STALE","message":"Service configuration changed after the plan was created"}`)) + case 3: + w.Write([]byte(plan(secondVersion, "release"))) + case 4: + if !tc.wantSecond { + t.Fatal("unexpected second PUT") + } + if got := r.Header.Get("If-Match"); got != `"`+secondVersion+`"` { + t.Errorf("replacement If-Match = %q", got) + } + w.Write([]byte(plan(secondVersion, "release"))) + default: + t.Fatalf("unexpected request %d", calls) + } + })) + defer s.Close() + writeConfig(t, s.URL) + d := t.TempDir() + writeManifest(t, d, imageManifest) + app, out := testApp(t, d, s.Client()) + if tc.input != "" { + app.IsInteractive = func() bool { return true } + app.In = strings.NewReader(tc.input) + } + err := execute(app, tc.args...) + if tc.wantSecond && err != nil { + t.Fatal(err) + } + if !tc.wantSecond && (err == nil || !strings.Contains(err.Error(), "review the new plan")) { + t.Fatalf("error = %v", err) + } + if calls != tc.wantCalls || !strings.Contains(out.String(), "develop -> main") || !strings.Contains(out.String(), "release -> main") { + t.Fatalf("calls=%d output=%q", calls, out.String()) + } + }) + } +} + +func TestApplyStopsAfterRepeatedStalePlans(t *testing.T) { + const version = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + plan := func(index int) string { + return fmt.Sprintf(`{"target":{"project":{"slug":"app"},"environment":{"name":"prod"},"service":{"id":"s","name":"web"}},"action":"updated","currentVersion":%q,"changes":[{"field":"name","from":%q,"to":"web"}]}`, version, fmt.Sprintf("remote-%d", index)) + } + calls := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method == http.MethodPut { + w.WriteHeader(http.StatusConflict) + w.Write([]byte(`{"code":"CONFIGURATION_PLAN_STALE","message":"changed"}`)) + return + } + w.Write([]byte(plan(calls))) + })) + defer s.Close() + writeConfig(t, s.URL) + d := t.TempDir() + writeManifest(t, d, imageManifest) + app, out := testApp(t, d, s.Client()) + app.IsInteractive = func() bool { return true } + app.In = strings.NewReader("y\ny\ny\n") + err := execute(app, "apply") + if err == nil || !strings.Contains(err.Error(), "keeps changing") || calls != 7 { + t.Fatalf("error=%v calls=%d", err, calls) + } + if !strings.Contains(out.String(), "remote-7 -> web") { + t.Fatalf("latest plan not displayed: %s", out.String()) + } +} + +func TestApplyYesMachineOutputUsesApplyResponse(t *testing.T) { + const plan = `{"target":{"project":{"slug":"app"},"environment":{"name":"prod"},"service":{"id":"s","name":"web"}},"action":"updated","currentVersion":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","changes":[{"field":"name","from":"planned-old","to":"web"}]}` + const applied = `{"target":{"project":{"slug":"app"},"environment":{"name":"prod"},"service":{"id":"s","name":"web"}},"action":"updated","currentVersion":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","changes":[{"field":"name","from":"applied-old","to":"web"}]}` + for _, mode := range []string{"--agent", "--json"} { + t.Run(mode, func(t *testing.T) { + requests := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + if requests == 1 { + w.Write([]byte(plan)) + return + } + w.Write([]byte(applied)) + })) + defer s.Close() + writeConfig(t, s.URL) + d := t.TempDir() + writeManifest(t, d, imageManifest) + app, out := testApp(t, d, s.Client()) + if err := execute(app, mode, "apply", "--yes"); err != nil { + t.Fatal(err) + } + var result applyResponse + if mode == "--agent" { + if json.Unmarshal(out.Bytes(), &result) != nil { + t.Fatalf("output=%q", out.String()) + } + } else { + var envelope struct { + OK bool `json:"ok"` + Data applyResponse `json:"data"` + Summary string `json:"summary"` + } + if json.Unmarshal(out.Bytes(), &envelope) != nil || !envelope.OK || envelope.Summary != "Applied" { + t.Fatalf("output=%q", out.String()) + } + result = envelope.Data + } + if requests != 2 || len(result.Changes) != 1 || result.Changes[0].From != "applied-old" { + t.Fatalf("requests=%d output=%q", requests, out.String()) + } + }) + } +} + +func TestApplyStaleMachineErrorIncludesReplacementPlan(t *testing.T) { + const first = `{"target":{"project":{"slug":"app"},"environment":{"name":"prod"},"service":{"id":"s","name":"web"}},"action":"updated","currentVersion":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","changes":[{"field":"name","from":"old","to":"web"}]}` + const replacement = `{"target":{"project":{"slug":"app"},"environment":{"name":"prod"},"service":{"id":"s","name":"web"}},"action":"updated","currentVersion":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","changes":[{"field":"name","from":"newer","to":"web"}]}` + calls := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + switch calls { + case 1: + w.Write([]byte(first)) + case 2: + w.WriteHeader(http.StatusConflict) + w.Write([]byte(`{"code":"CONFIGURATION_PLAN_STALE","message":"changed"}`)) + case 3: + w.Write([]byte(replacement)) + default: + t.Fatalf("unexpected request %d", calls) + } + })) + defer s.Close() + writeConfig(t, s.URL) + d := t.TempDir() + writeManifest(t, d, imageManifest) + app, out := testApp(t, d, s.Client()) + err := execute(app, "--agent", "apply", "--yes") + if err == nil || calls != 3 { + t.Fatalf("error=%v calls=%d", err, calls) + } + if err := output.Error(out, fmt.Errorf("apply failed: %w", err)); err != nil { + t.Fatal(err) + } + var envelope struct { + OK bool `json:"ok"` + Error string `json:"error"` + Plan applyResponse `json:"plan"` + } + if json.Unmarshal(out.Bytes(), &envelope) != nil || envelope.OK || envelope.Plan.Changes[0].From != "newer" { + t.Fatalf("output=%s", out.String()) + } +} + +func TestApplyOutputFormatsStructuredValuesForHumans(t *testing.T) { + var out bytes.Buffer + printApplyResult(&out, "Plan", applyResponse{ + Action: "updated", + Changes: []applyChange{{ + Field: "ports", + From: []any{map[string]any{ + "containerPort": float64(8080), + "domain": nil, + "public": false, + }}, + To: []any{map[string]any{ + "containerPort": float64(8080), + "domain": "api.example.com", + "public": true, + }}, + }}, + }) + + assertHumanOutput( + t, + out.String(), + "ports: [(containerPort=8080, domain=null, public=false)] -> [(containerPort=8080, domain=api.example.com, public=true)]", + ) +} + +func TestServiceBaseRejectsMissingTarget(t *testing.T) { + if _, err := serviceBase(manifest.Manifest{}); err == nil || !strings.Contains(err.Error(), "not linked") { + t.Fatalf("error = %v", err) + } +} + func TestCollectionRequestsFollowPagination(t *testing.T) { queries := map[string][]string{} s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -419,7 +841,7 @@ func TestMissingIDsFailLocally(t *testing.T) { for _, command := range []string{"apply", "deploy", "status", "logs"} { t.Run(command, func(t *testing.T) { d := t.TempDir() - writeManifest(t, d, strings.ReplaceAll(imageManifest, "id: p, ", "")) + writeManifest(t, d, strings.ReplaceAll(imageManifest, "target: {serviceId: s}\n", "")) app, _ := testApp(t, d, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network request") return nil, nil @@ -490,14 +912,14 @@ func TestStatusAndResourceRoutesAndOutput(t *testing.T) { var gotPath, gotQuery string s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotQuery = r.URL.Path, r.URL.RawQuery - w.Write([]byte(`{"service":{"id":"0400075c-69aa-46c2-bccc-fc172b8c6b28","name":"web","source":{"type":"image","image":"nginx"}},"latestBuild":null,"latestRollout":null,"deployments":[],"items":[]}`)) + w.Write([]byte(`{"target":{"project":{"id":"p","slug":"app"},"environment":{"id":"e","name":"prod"},"service":{"id":"s","name":"web"}},"service":{"id":"0400075c-69aa-46c2-bccc-fc172b8c6b28","name":"web","source":{"type":"image","image":"nginx"}},"latestBuild":null,"latestRollout":null,"deployments":[],"items":[]}`)) })) defer s.Close() writeConfig(t, s.URL) for _, mode := range []string{"--agent", "--json"} { app, out := testApp(t, t.TempDir(), s.Client()) args := append([]string{mode}, tc.args...) - args = append(args, "--project", "p", "--environment", "e", "--service", "s") + args = append(args, "--service", "s") if err := execute(app, args...); err != nil { t.Fatal(err) } @@ -508,13 +930,13 @@ func TestStatusAndResourceRoutesAndOutput(t *testing.T) { } if tc.args[0] == "status" { app, out := testApp(t, t.TempDir(), s.Client()) - args := append(tc.args, "--project", "p", "--environment", "e", "--service", "s") + args := append(tc.args, "--service", "s") if err := execute(app, args...); err != nil { t.Fatal(err) } - assertHumanOutput(t, out.String(), "ID", "0400075c-69aa-46c2-bccc-fc172b8c6b28") + assertHumanOutput(t, out.String(), "app/prod/web", "ID", "0400075c-69aa-46c2-bccc-fc172b8c6b28") } - base := "/api/v1/projects/p/environments/e/services/s" + base := "/api/v1/services/s" if gotPath != base+tc.path || gotQuery != tc.query { t.Fatalf("got %s?%s", gotPath, gotQuery) } @@ -544,7 +966,7 @@ func TestBuildsHumanOutputIsFormatted(t *testing.T) { writeConfig(t, s.URL) app, out := testApp(t, t.TempDir(), s.Client()) - err := execute(app, "builds", "--project", "p", "--environment", "e", "--service", "s") + err := execute(app, "builds", "--service", "s") if err != nil { t.Fatal(err) } @@ -558,7 +980,7 @@ func TestConfigurationHumanOutputIsFormatted(t *testing.T) { writeConfig(t, s.URL) app, out := testApp(t, t.TempDir(), s.Client()) - err := execute(app, "config", "--project", "p", "--environment", "e", "--service", "s") + err := execute(app, "config", "--service", "s") if err != nil { t.Fatal(err) } @@ -570,7 +992,7 @@ func TestMetricsHumanOutputIsFormatted(t *testing.T) { writeConfig(t, s.URL) app, out := testApp(t, t.TempDir(), s.Client()) - err := execute(app, "metrics", "--project", "p", "--environment", "e", "--service", "s") + err := execute(app, "metrics", "--service", "s") if err != nil { t.Fatal(err) } @@ -585,7 +1007,7 @@ func TestMetricsHumanOutputShowsDisabledProvider(t *testing.T) { writeConfig(t, s.URL) app, out := testApp(t, t.TempDir(), s.Client()) - err := execute(app, "metrics", "--project", "p", "--environment", "e", "--service", "s") + err := execute(app, "metrics", "--service", "s") if err != nil { t.Fatal(err) } @@ -597,7 +1019,7 @@ func TestRevisionsHumanOutputIsFormatted(t *testing.T) { writeConfig(t, s.URL) app, out := testApp(t, t.TempDir(), s.Client()) - err := execute(app, "revisions", "--project", "p", "--environment", "e", "--service", "s") + err := execute(app, "revisions", "--service", "s") if err != nil { t.Fatal(err) } @@ -607,11 +1029,11 @@ func TestRevisionsHumanOutputIsFormatted(t *testing.T) { func TestRolloutHumanOutputIsFormatted(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/v1/projects/p/environments/e/services/s/rollouts": + case "/api/v1/services/s/rollouts": w.Write([]byte(`{"rollouts":[{"id":"2c917d90-4bc1-4274-b3bf-34fed009fc12","status":"in_progress","currentStage":"health_check","createdAt":"2026-07-21T11:04:59.46Z","completedAt":null,"deployments":[{"serverName":"ubuntu-2","phase":"running","healthStatus":"healthy"}]}],"nextCursor":"next-page"}`)) - case "/api/v1/projects/p/environments/e/services/s/rollouts/r1": + case "/api/v1/services/s/rollouts/r1": w.Write([]byte(`{"rollout":{"id":"2c917d90-4bc1-4274-b3bf-34fed009fc12","status":"completed","currentStage":"completed","createdAt":"2026-07-21T11:04:59.46Z","completedAt":"2026-07-21T11:05:30Z","deployments":[]}}`)) - case "/api/v1/projects/p/environments/e/services/s/rollouts/r1/logs": + case "/api/v1/services/s/rollouts/r1/logs": w.Write([]byte(`{"provider":"enabled","logs":[{"message":"Rollout started","stage":"preparing","timestamp":"2026-07-21T11:04:59.46Z"},{"message":"Starting container","stage":"health_check","timestamp":"2026-07-21T11:05:00.529Z"}]}`)) default: t.Fatalf("unexpected path %s", r.URL.Path) @@ -629,7 +1051,7 @@ func TestRolloutHumanOutputIsFormatted(t *testing.T) { {[]string{"rollout", "logs", "r1"}, []string{"Rollout logs (2)", "[preparing]", "Rollout started", "[health check]", "Starting container"}}, } { app, out := testApp(t, t.TempDir(), s.Client()) - args := append(tc.args, "--project", "p", "--environment", "e", "--service", "s") + args := append(tc.args, "--service", "s") if err := execute(app, args...); err != nil { t.Fatal(err) } @@ -674,7 +1096,7 @@ func TestLogsDrainAvailablePagesWithoutSleeping(t *testing.T) { requests++ switch requests { case 1: - w.Write([]byte(`{"provider":"enabled","logs":[],"nextCursor":"page-1"}`)) + w.Write([]byte(`{"target":{"project":{"id":"p","slug":"app"},"environment":{"id":"e","name":"prod"},"service":{"id":"s","name":"web"}},"provider":"enabled","logs":[],"nextCursor":"page-1"}`)) case 2: if got := r.URL.Query().Get("cursor"); got != "page-1" { t.Errorf("cursor=%q", got) @@ -702,7 +1124,7 @@ func TestLogsDrainAvailablePagesWithoutSleeping(t *testing.T) { if requests != 3 || sleeps != 0 { t.Fatalf("requests=%d sleeps=%d", requests, sleeps) } - if !strings.Contains(out.String(), "same-time-a") || !strings.Contains(out.String(), "same-time-b") { + if !strings.Contains(out.String(), "app/prod/web") || !strings.Contains(out.String(), "same-time-a") || !strings.Contains(out.String(), "same-time-b") { t.Fatalf("missing equal-timestamp logs in output:\n%s", out.String()) } } diff --git a/cli/internal/cli/types.go b/cli/internal/cli/types.go index 59359330..c3155b1b 100644 --- a/cli/internal/cli/types.go +++ b/cli/internal/cli/types.go @@ -41,10 +41,41 @@ type environmentItem struct { Name string `json:"name"` } type serviceItem struct { - ID string `json:"id"` - Name string `json:"name"` - Hostname *string `json:"hostname"` - Source manifest.Source `json:"source"` + ID string `json:"id"` + Name string `json:"name"` + Hostname *string `json:"hostname"` + Source manifest.Source `json:"source"` + Ports []manifest.Port `json:"ports"` + Replicas int `json:"replicas"` + Placement *manifest.Placement `json:"placement"` + HealthCheck *manifest.HealthCheck `json:"healthCheck"` + StartCommand *string `json:"startCommand"` + Resources *manifest.Resources `json:"resources"` +} +type targetProject struct { + ID string `json:"id"` + Slug string `json:"slug"` +} +type targetEnvironment struct { + ID string `json:"id"` + Name string `json:"name"` +} +type targetService struct { + ID string `json:"id"` + Name string `json:"name"` +} +type targetContext struct { + Project targetProject `json:"project"` + Environment targetEnvironment `json:"environment"` + Service targetService `json:"service"` +} +type managementBlocker struct { + Code string `json:"code"` + Message string `json:"message"` +} +type serviceManagement struct { + Patchable bool `json:"patchable"` + Blockers []managementBlocker `json:"blockers"` } type projectsResponse struct { Projects []projectItem `json:"projects"` @@ -59,8 +90,16 @@ type servicesResponse struct { NextCursor string `json:"nextCursor,omitempty"` } type applyResponse struct { - Action string `json:"action"` - Changes []string `json:"changes"` + Target targetContext `json:"target"` + Action string `json:"action"` + CurrentVersion string `json:"currentVersion"` + DesiredVersion string `json:"desiredVersion"` + Changes []applyChange `json:"changes"` +} +type applyChange struct { + Field string `json:"field"` + From any `json:"from"` + To any `json:"to"` } type deployResponse struct { Operation string `json:"operation"` @@ -69,6 +108,7 @@ type deployResponse struct { BuildID *string `json:"buildId"` } type statusResponse struct { + Target targetContext `json:"target"` Service struct { ID string `json:"id"` Name string `json:"name"` @@ -86,9 +126,10 @@ type serviceLog struct { } type logsResponse struct { - Provider string `json:"provider"` - Logs []serviceLog `json:"logs"` - NextCursor string `json:"nextCursor"` - HasMore bool `json:"hasMore"` - PollAfterMS int `json:"pollAfterMs"` + Target targetContext `json:"target"` + Provider string `json:"provider"` + Logs []serviceLog `json:"logs"` + NextCursor string `json:"nextCursor"` + HasMore bool `json:"hasMore"` + PollAfterMS int `json:"pollAfterMs"` } diff --git a/cli/internal/manifest/manifest.go b/cli/internal/manifest/manifest.go index d929bb54..eb680d2a 100644 --- a/cli/internal/manifest/manifest.go +++ b/cli/internal/manifest/manifest.go @@ -13,24 +13,21 @@ import ( "gopkg.in/yaml.v3" ) -var windowsAbsolutePath = regexp.MustCompile(`^[A-Za-z]:[\\/]`) +var ( + windowsAbsolutePath = regexp.MustCompile(`^[A-Za-z]:[\\/]`) + hostnamePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + slugChars = regexp.MustCompile(`[^a-z0-9]+`) +) type Manifest struct { - APIVersion string `json:"apiVersion" yaml:"apiVersion"` - Project Project `json:"project" yaml:"project"` - Environment Environment `json:"environment" yaml:"environment"` - Service Service `json:"service" yaml:"service"` -} -type Project struct { - ID string `json:"id,omitempty" yaml:"id,omitempty"` - Slug string `json:"slug" yaml:"slug"` + APIVersion string `json:"apiVersion" yaml:"apiVersion"` + Target *Target `json:"target,omitempty" yaml:"target,omitempty"` + Service Service `json:"service" yaml:"service"` } -type Environment struct { - ID string `json:"id,omitempty" yaml:"id,omitempty"` - Name string `json:"name" yaml:"name"` +type Target struct { + ServiceID string `json:"serviceId,omitempty" yaml:"serviceId,omitempty"` } type Service struct { - ID string `json:"id,omitempty" yaml:"id,omitempty"` Name string `json:"name" yaml:"name"` Source Source `json:"source" yaml:"source"` Hostname *string `json:"hostname" yaml:"hostname"` @@ -115,11 +112,9 @@ func Save(path string, m Manifest) error { } func ApplyDefaults(m *Manifest) { m.APIVersion = strings.TrimSpace(m.APIVersion) - m.Project.ID = strings.TrimSpace(m.Project.ID) - m.Project.Slug = strings.TrimSpace(m.Project.Slug) - m.Environment.ID = strings.TrimSpace(m.Environment.ID) - m.Environment.Name = strings.TrimSpace(m.Environment.Name) - m.Service.ID = strings.TrimSpace(m.Service.ID) + if m.Target != nil { + m.Target.ServiceID = strings.TrimSpace(m.Target.ServiceID) + } m.Service.Name = strings.TrimSpace(m.Service.Name) s := &m.Service.Source s.Type = strings.ToLower(strings.TrimSpace(s.Type)) @@ -175,12 +170,6 @@ func Validate(m Manifest) error { if m.APIVersion != "v1" { return errors.New("apiVersion must be v1") } - if m.Project.Slug == "" { - return errors.New("project.slug is required") - } - if m.Environment.Name == "" { - return errors.New("environment.name is required") - } if m.Service.Name == "" { return errors.New("service.name is required") } @@ -219,9 +208,15 @@ func Validate(m Manifest) error { default: return errors.New("service.source.type must be image or github") } - if m.Service.Hostname != nil && *m.Service.Hostname == "" { + if m.Service.Hostname == nil { + return errors.New("service.hostname is required") + } + if *m.Service.Hostname == "" { return errors.New("service.hostname cannot be blank") } + if !hostnamePattern.MatchString(*m.Service.Hostname) || len(*m.Service.Hostname) > 63 { + return errors.New("service.hostname must be at most 63 lowercase letters, numbers, and hyphen-separated segments") + } if m.Service.StartCommand != nil && *m.Service.StartCommand == "" { return errors.New("service.startCommand cannot be blank") } @@ -315,7 +310,7 @@ func Validate(m Manifest) error { return nil } func (m Manifest) Linked() bool { - return m.Project.ID != "" && m.Environment.ID != "" && m.Service.ID != "" + return m.Target != nil && strings.TrimSpace(m.Target.ServiceID) != "" } func CanonicalGitHubRepository(value string) (string, error) { u, err := url.Parse(strings.TrimSpace(value)) @@ -336,8 +331,10 @@ func CanonicalGitHubRepository(value string) (string, error) { return "https://github.com/" + parts[0] + "/" + parts[1], nil } -var slugChars = regexp.MustCompile(`[^a-z0-9]+`) - func Slugify(v string) string { - return strings.Trim(slugChars.ReplaceAllString(strings.ToLower(v), "-"), "-") + value := strings.Trim(slugChars.ReplaceAllString(strings.ToLower(v), "-"), "-") + if len(value) > 63 { + value = strings.Trim(value[:63], "-") + } + return value } diff --git a/cli/internal/manifest/manifest_test.go b/cli/internal/manifest/manifest_test.go index b18b5709..57cdcbb7 100644 --- a/cli/internal/manifest/manifest_test.go +++ b/cli/internal/manifest/manifest_test.go @@ -6,7 +6,8 @@ import ( ) func base() Manifest { - return Manifest{APIVersion: "v1", Project: Project{ID: "p", Slug: "app"}, Environment: Environment{ID: "e", Name: "prod"}, Service: Service{ID: "s", Name: "web", Source: Source{Type: "image", Image: "nginx"}, Replicas: 1, Placement: &Placement{Mode: "automatic"}}} + hostname := "web" + return Manifest{APIVersion: "v1", Target: &Target{ServiceID: "s"}, Service: Service{Name: "web", Source: Source{Type: "image", Image: "nginx"}, Hostname: &hostname, Replicas: 1, Placement: &Placement{Mode: "automatic"}}} } func TestDefaultsAndRoundTrip(t *testing.T) { m := base() @@ -21,6 +22,24 @@ func TestDefaultsAndRoundTrip(t *testing.T) { } } +func TestHostnameValidationAndSlugify(t *testing.T) { + for _, value := range []string{"", "Upper", "two words", "-leading", "trailing-", "two--hyphens", strings.Repeat("a", 64)} { + m := base() + m.Service.Hostname = &value + if err := Validate(m); err == nil { + t.Fatalf("invalid hostname %q accepted", value) + } + } + + if got := Slugify(" My API__Service "); got != "my-api-service" { + t.Fatalf("Slugify() = %q", got) + } + long := Slugify(strings.Repeat("long-name-", 10)) + if len(long) > 63 || strings.HasSuffix(long, "-") || !hostnamePattern.MatchString(long) { + t.Fatalf("invalid truncated slug %q", long) + } +} + func TestPlacementRoundTripAndValidation(t *testing.T) { m := base() m.Service.Replicas = 3 @@ -65,11 +84,10 @@ func TestPlacementRoundTripAndValidation(t *testing.T) { func TestPlacementIsRequired(t *testing.T) { _, err := Parse([]byte(`apiVersion: v1 -project: {slug: app} -environment: {name: prod} service: name: web source: {type: image, image: nginx} + hostname: web replicas: 2 `)) if err == nil || !strings.Contains(err.Error(), "service.placement is required") { diff --git a/cli/internal/output/output.go b/cli/internal/output/output.go index 853e18cc..d9842f20 100644 --- a/cli/internal/output/output.go +++ b/cli/internal/output/output.go @@ -2,6 +2,7 @@ package output import ( "encoding/json" + "errors" "fmt" "io" "strings" @@ -17,6 +18,12 @@ type Envelope struct { type ErrorEnvelope struct { OK bool `json:"ok"` Error string `json:"error"` + Plan any `json:"plan,omitempty"` +} + +type errorWithPlan interface { + error + PlanData() any } func JSON(w io.Writer, value any) error { @@ -29,7 +36,12 @@ func OK(w io.Writer, data any, summary string) error { } func Error(w io.Writer, err error) error { - return JSON(w, ErrorEnvelope{OK: false, Error: err.Error()}) + envelope := ErrorEnvelope{OK: false, Error: err.Error()} + var planned errorWithPlan + if errors.As(err, &planned) { + envelope.Plan = planned.PlanData() + } + return JSON(w, envelope) } func Section(w io.Writer, title string) { diff --git a/docs/api/public-api.mdx b/docs/api/public-api.mdx index 2f2dd5cc..dfbfb153 100644 --- a/docs/api/public-api.mdx +++ b/docs/api/public-api.mdx @@ -46,20 +46,20 @@ Content-Type: application/json } ``` -## Authorization and containment +## Authorization and service identity Roles are global for the current Techulus Cloud installation. There are no project-level permissions. - `reader`, `developer`, and `admin` can read resources. - `developer` and `admin` can change configuration and deploy services. -Service URLs always include the project and environment: +Service operations use the globally unique service ID: ```text -/api/v1/projects/{projectId}/environments/{environmentId}/services/{serviceId} +/api/v1/services/{serviceId} ``` -The API returns `404 NOT_FOUND` when an ID exists but does not belong to the parent IDs in the URL. +Nested project and environment paths remain available for browsing collections. Service responses include an authoritative `target` with the containing project, environment, and service labels. ## Errors @@ -82,7 +82,7 @@ Use `code` for automation. Do not match the human-readable `message`. | `GET` | `/api/v1/projects` | List projects | | `GET` | `/api/v1/projects/{projectId}/environments` | List environments in a project | | `GET` | `/api/v1/projects/{projectId}/environments/{environmentId}/services` | List services in an environment | -| `GET` | `/api/v1/projects/{projectId}/environments/{environmentId}/services/{serviceId}` | Return one contained service | +| `GET` | `/api/v1/services/{serviceId}` | Return the target and canonical service configuration | Collection responses use an opaque keyset cursor: @@ -105,11 +105,12 @@ Pass `nextCursor` as `?cursor=...`. `limit` defaults to 100 and accepts values f ## Service resources -The paths in this table are relative to the nested service URL. +The paths in this table are relative to `/api/v1/services/{serviceId}`. | Method | Path suffix | Description | | --- | --- | --- | -| `GET`, `PATCH` | `/configuration` | Read safe current/active configuration or atomically apply the managed subset | +| `GET`, `PUT` | `/configuration` | Read safe current/active configuration or atomically replace all managed configuration | +| `POST` | `/configuration/plan` | Validate and plan a complete managed configuration replacement | | `GET` | `/status` | Read source, latest build and rollout, and persisted deployments | | `POST` | `/deploy` | Queue an image rollout or GitHub build | | `GET` | `/logs` | Search logs and optionally long poll | @@ -136,12 +137,15 @@ A GitHub service keeps mutable repository settings in `current.source`. Its `act Configuration and revision responses never include secret names, values, or ciphertext. -### Patch configuration +### Replace configuration -`PATCH /configuration` is atomic. Every field is optional. Omitted fields remain unchanged. An included `ports` array replaces the entire managed port set. +`POST /configuration/plan` accepts the same complete desired configuration as `PUT /configuration`. It performs no writes and returns the authoritative `target`, `action`, `currentVersion`, `desiredVersion`, and structured `changes` (`field`, `from`, and `to`). Service IDs on these flat service routes are authorized installation-globally rather than being scoped by a project ID in the URL. + +`PUT /configuration` is atomic and replaces the complete managed configuration. Send the `currentVersion` returned by the plan as one quoted strong ETag (for example, `If-Match: "sha256:…"`). Missing, unquoted, multiple, weak, or otherwise invalid headers are rejected; a service change after planning returns `409 CONFIGURATION_PLAN_STALE`. A successful response returns the authoritative target and the structured change set that was applied. The request must contain exactly `name`, `source`, `hostname`, `ports`, `placement`, `healthCheck`, `startCommand`, and `resources`. Omitted or unknown fields are rejected. `hostname` must be concrete and non-null. Use `null` to clear nullable fields, including `resources`. ```json { + "name": "web", "source": { "type": "github", "repository": "https://github.com/techulus/cloud", @@ -175,6 +179,8 @@ Configuration and revision responses never include secret names, values, or ciph } ``` +The `tc apply` command sends this complete replacement. A linked `techulus.yml` stores only `target.serviceId`; desired service configuration remains under `service`. Explicit CLI targeting uses `--service `. During interactive `tc link`, a project with exactly one environment selects it automatically; zero environments is an error and multiple environments prompt for a choice. + The API supports these source variants: ```json @@ -190,7 +196,7 @@ The API supports these source variants: } ``` -GitHub repository URLs are canonical HTTPS `github.com` URLs. `rootDir` must stay inside the repository. Use `rootDir: null` to clear an existing build root. Source conversion and GitHub repository switching are not supported. +GitHub repository URLs are canonical HTTPS `github.com` URLs. `rootDir` is required and must either stay inside the repository or be `null` to clear an existing build root. Source conversion and GitHub repository switching are not supported. Use automatic placement for stateless services: @@ -312,4 +318,4 @@ The CLI uses the same endpoints documented above: | `tc metrics` | Query service metrics | | `tc revisions` | List the redacted revision changelog | -`tc link` stores the selected project, environment, and service IDs in `techulus.yml`. Image and GitHub services use the same `tc link`, `tc apply`, `tc deploy`, and inspection commands. +`tc link` stores only the selected `target.serviceId` in `techulus.yml`. Relinking a manifest to a different service requires removing `target.serviceId` first. `tc apply` always displays the server-generated plan and prompts before writing; use `tc apply --yes` for noninteractive automation. Image and GitHub services use the same `tc link`, `tc apply`, `tc deploy`, and inspection commands. diff --git a/web/actions/projects.ts b/web/actions/projects.ts index b95fbb29..27d3f370 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -51,7 +51,10 @@ import type { HealthCheckConfig as ServiceHealthCheckConfig, } from "@/lib/service-config"; import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config"; -import { findServicePortValidationIssue } from "@/lib/service-revision-spec"; +import { + findServicePortValidationIssue, + getDefaultServiceHostname, +} from "@/lib/service-revision-spec"; import type { DeleteConfirmation } from "@/lib/two-factor"; import { getZodErrorMessage, slugify } from "@/lib/utils"; import { @@ -281,7 +284,10 @@ export async function createService(input: CreateServiceInput) { } const id = randomUUID(); - const hostname = `${project.slug}-${slugify(name)}-${env.name}`; + const hostname = getDefaultServiceHostname( + `${project.slug}-${name}-${env.name}`, + id, + ); const newServiceCanvasPosition = { canvasX: (SERVICE_CANVAS_WIDTH - SERVICE_CARD_WIDTH) / 2, canvasY: 0, @@ -661,23 +667,35 @@ export async function updateServiceHostname( } const sanitized = slugify(hostname); - if (!sanitized) { - throw new Error("Invalid hostname"); + if (!sanitized || sanitized.length > 63) { + throw new Error( + "Hostname must be a valid DNS label of at most 63 characters", + ); } - const existing = await db - .select({ id: services.id }) - .from(services) - .where(eq(services.hostname, sanitized)); + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`); + const current = await tx + .select({ id: services.id }) + .from(services) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .limit(1) + .then((rows) => rows[0]); + if (!current) throw new Error("Service not found"); + const existing = await tx + .select({ id: services.id }) + .from(services) + .where(eq(services.hostname, sanitized)); - if (existing.some((s) => s.id !== serviceId)) { - throw new Error("Hostname is already in use"); - } + if (existing.some((s) => s.id !== serviceId)) { + throw new Error("Hostname is already in use"); + } - await db - .update(services) - .set({ hostname: sanitized }) - .where(eq(services.id, serviceId)); + await tx + .update(services) + .set({ hostname: sanitized }) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))); + }); return { success: true, hostname: sanitized }; } @@ -687,10 +705,27 @@ export async function updateServiceName(serviceId: string, name: string) { try { const validatedName = nameSchema.parse(name); - await db - .update(services) - .set({ name: validatedName }) - .where(eq(services.id, serviceId)); + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, + ); + const current = await tx + .select({ name: services.name, hostname: services.hostname }) + .from(services) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .limit(1) + .then((rows) => rows[0]); + if (!current) throw new Error("Service not found"); + await tx + .update(services) + .set({ + name: validatedName, + hostname: + current.hostname ?? + getDefaultServiceHostname(current.name, serviceId), + }) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))); + }); return { success: true, name: validatedName }; } catch (error) { @@ -738,6 +773,9 @@ export async function updateServiceGithubRepo( } await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, + ); await tx .update(services) .set(updateData) @@ -794,16 +832,19 @@ export async function updateServiceHealthCheck( throw new Error("Service not found"); } - await db - .update(services) - .set({ - healthCheckCmd: config.cmd, - healthCheckInterval: config.interval, - healthCheckTimeout: config.timeout, - healthCheckRetries: config.retries, - healthCheckStartPeriod: config.startPeriod, - }) - .where(eq(services.id, serviceId)); + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`); + await tx + .update(services) + .set({ + healthCheckCmd: config.cmd, + healthCheckInterval: config.interval, + healthCheckTimeout: config.timeout, + healthCheckRetries: config.retries, + healthCheckStartPeriod: config.startPeriod, + }) + .where(eq(services.id, serviceId)); + }); return { success: true }; } @@ -818,10 +859,13 @@ export async function updateServiceStartCommand( throw new Error("Service not found"); } - await db - .update(services) - .set({ startCommand }) - .where(eq(services.id, serviceId)); + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`); + await tx + .update(services) + .set({ startCommand }) + .where(eq(services.id, serviceId)); + }); return { success: true }; } @@ -854,13 +898,16 @@ export async function updateServiceResourceLimits( throw new Error("Service not found"); } - await db - .update(services) - .set({ - resourceCpuLimit: validated.cpuCores, - resourceMemoryLimitMb: validated.memoryMb, - }) - .where(eq(services.id, serviceId)); + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`); + await tx + .update(services) + .set({ + resourceCpuLimit: validated.cpuCores, + resourceMemoryLimitMb: validated.memoryMb, + }) + .where(eq(services.id, serviceId)); + }); return { success: true }; } @@ -1064,37 +1111,42 @@ export async function updateServiceConfig( throw new Error("Service not found"); } - if (config.source) { - await db - .update(services) - .set({ image: config.source.image }) - .where(eq(services.id, serviceId)); - } + if (config.source || config.healthCheck !== undefined) { + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, + ); + if (config.source) { + await tx + .update(services) + .set({ image: config.source.image }) + .where(eq(services.id, serviceId)); + } - if (config.healthCheck !== undefined) { - if (config.healthCheck === null) { - await db - .update(services) - .set({ - healthCheckCmd: null, - healthCheckInterval: null, - healthCheckTimeout: null, - healthCheckRetries: null, - healthCheckStartPeriod: null, - }) - .where(eq(services.id, serviceId)); - } else { - await db - .update(services) - .set({ - healthCheckCmd: config.healthCheck.cmd, - healthCheckInterval: config.healthCheck.interval, - healthCheckTimeout: config.healthCheck.timeout, - healthCheckRetries: config.healthCheck.retries, - healthCheckStartPeriod: config.healthCheck.startPeriod, - }) - .where(eq(services.id, serviceId)); - } + if (config.healthCheck === null) { + await tx + .update(services) + .set({ + healthCheckCmd: null, + healthCheckInterval: null, + healthCheckTimeout: null, + healthCheckRetries: null, + healthCheckStartPeriod: null, + }) + .where(eq(services.id, serviceId)); + } else if (config.healthCheck !== undefined) { + await tx + .update(services) + .set({ + healthCheckCmd: config.healthCheck.cmd, + healthCheckInterval: config.healthCheck.interval, + healthCheckTimeout: config.healthCheck.timeout, + healthCheckRetries: config.healthCheck.retries, + healthCheckStartPeriod: config.healthCheck.startPeriod, + }) + .where(eq(services.id, serviceId)); + } + }); } if (config.ports) { @@ -1552,19 +1604,24 @@ export async function removeServiceVolume(volumeId: string) { throw new Error("Stop the service before removing volumes"); } - await db.delete(serviceVolumes).where(eq(serviceVolumes.id, volumeId)); + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${volume[0].serviceId}))`, + ); + await tx.delete(serviceVolumes).where(eq(serviceVolumes.id, volumeId)); - const remainingVolumes = await db - .select({ id: serviceVolumes.id }) - .from(serviceVolumes) - .where(eq(serviceVolumes.serviceId, volume[0].serviceId)); + const remainingVolumes = await tx + .select({ id: serviceVolumes.id }) + .from(serviceVolumes) + .where(eq(serviceVolumes.serviceId, volume[0].serviceId)); - if (remainingVolumes.length === 0 && service.stateful) { - await db - .update(services) - .set({ stateful: false }) - .where(eq(services.id, service.id)); - } + if (remainingVolumes.length === 0 && service.stateful) { + await tx + .update(services) + .set({ stateful: false }) + .where(eq(services.id, service.id)); + } + }); return { success: true }; } diff --git a/web/app/(dashboard)/dashboard/page.tsx b/web/app/(dashboard)/dashboard/page.tsx index 7b75f3f0..6e716e16 100644 --- a/web/app/(dashboard)/dashboard/page.tsx +++ b/web/app/(dashboard)/dashboard/page.tsx @@ -1,6 +1,13 @@ import { Box } from "lucide-react"; import Link from "next/link"; import { ClusterHealthSummary } from "@/components/cluster/cluster-health-summary"; +import { + SUMMARY_CARD_CLASSNAME, + SUMMARY_CARD_MIN_HEIGHT, + SummaryCardStat, + SummaryCardTitle, + SummaryCardValue, +} from "@/components/core/summary-card"; import { CreateProjectDialog } from "@/components/project/create-project-dialog"; import { CreateServerDialog } from "@/components/server/create-server-dialog"; import { ServerList } from "@/components/server/server-list"; @@ -11,14 +18,6 @@ import { EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; -import { - Item, - ItemContent, - ItemDescription, - ItemGroup, - ItemMedia, - ItemTitle, -} from "@/components/ui/item"; import { getClusterHealth, listProjects, listServers } from "@/db/queries"; export default async function DashboardPage() { @@ -55,57 +54,61 @@ export default async function DashboardPage() { ) : ( - +
{projects.map((project) => ( - - } + href={`/dashboard/projects/${project.slug}/production`} + className={SUMMARY_CARD_CLASSNAME} + style={{ minHeight: SUMMARY_CARD_MIN_HEIGHT }} > - - - - - {project.name} - - {project.serviceCount === 0 - ? "No services" - : project.serviceCount === 1 - ? "1 service" - : `${project.serviceCount} services`} - - - + {project.name} +
+ + + {project.serviceCount === 0 ? ( + + none + + ) : ( + <> + {project.onlineServiceCount}/{project.serviceCount}{" "} + + online + + + )} + + + + + {project.environmentCount} + + +
+ ))} - +
)}
-
+

Servers

-

- Real-time infrastructure status and fleet management -

+ {servers.length > 0 ? ( + + ) : ( +

+ Real-time infrastructure status and fleet management +

+ )}
- {servers.length > 0 && ( - - )} -
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/page.tsx index 4fd623ed..edf8653a 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/page.tsx @@ -1,11 +1,7 @@ import { notFound } from "next/navigation"; import { SetBreadcrumbs } from "@/components/core/breadcrumb-data"; import { ServiceCanvas } from "@/components/service/service-canvas"; -import { - getEnvironmentByName, - getGlobalSettings, - getProjectBySlug, -} from "@/db/queries"; +import { getEnvironmentByName, getProjectBySlug } from "@/db/queries"; export default async function ProjectEnvironmentPage({ params, @@ -19,10 +15,7 @@ export default async function ProjectEnvironmentPage({ notFound(); } - const [environment, globalSettings] = await Promise.all([ - getEnvironmentByName(project.id, envName), - getGlobalSettings(), - ]); + const environment = await getEnvironmentByName(project.id, envName); if (!environment) { notFound(); @@ -45,7 +38,6 @@ export default async function ProjectEnvironmentPage({ projectSlug={slug} envId={environment.id} envName={environment.name} - edgeDomain={globalSettings.edgeDomain.hostname} /> diff --git a/web/app/api/inngest/route.ts b/web/app/api/inngest/route.ts index 1eaeab8d..fbc85d0c 100644 --- a/web/app/api/inngest/route.ts +++ b/web/app/api/inngest/route.ts @@ -1,11 +1,13 @@ import { serve } from "inngest/next"; import { inngest } from "@/lib/inngest/client"; import { + agentUpgradeTimeoutCheck, backupWorkflow, buildTriggerWorkflow, buildWorkflow, certificateRenewal, challengeCleanup, + controlPlaneUpdateCheck, expiredDeletedServicesPurge, migrationWorkflow, oldBackupsCleanup, @@ -34,6 +36,8 @@ export const { GET, POST, PUT } = serve({ scheduledBackupsCheck, oldBackupsCleanup, staleItemsCleanup, + controlPlaneUpdateCheck, + agentUpgradeTimeoutCheck, migrationWorkflow, backupWorkflow, restoreWorkflow, diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/route.ts deleted file mode 100644 index f696aa3c..00000000 --- a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { requireApiKeyRole } from "@/lib/api-auth"; -import { - apiError, - findNestedService, - notFound, - resolvePersistedSource, -} from "@/lib/public-api"; -export async function GET( - request: Request, - { - params, - }: { - params: Promise<{ - projectId: string; - environmentId: string; - serviceId: string; - }>; - }, -) { - const auth = await requireApiKeyRole(request, [ - "admin", - "developer", - "reader", - ]); - if (!auth.ok) return auth.response; - try { - const p = await params; - const service = await findNestedService( - p.projectId, - p.environmentId, - p.serviceId, - ); - if (!service) return notFound(); - return Response.json({ - service: { - id: service.id, - name: service.name, - hostname: service.hostname, - source: await resolvePersistedSource(service), - createdAt: service.createdAt, - }, - }); - } catch (error) { - console.error("[public-api] read service failed", error); - return apiError("Internal server error", "INTERNAL_ERROR", 500); - } -} diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/builds/route.ts b/web/app/api/v1/services/[serviceId]/builds/route.ts similarity index 100% rename from web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/builds/route.ts rename to web/app/api/v1/services/[serviceId]/builds/route.ts diff --git a/web/app/api/v1/services/[serviceId]/configuration/plan/route.ts b/web/app/api/v1/services/[serviceId]/configuration/plan/route.ts new file mode 100644 index 00000000..46b229cc --- /dev/null +++ b/web/app/api/v1/services/[serviceId]/configuration/plan/route.ts @@ -0,0 +1 @@ +export { postConfigurationPlanRoute as POST } from "@/lib/public-api-routes"; diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/configuration/route.ts b/web/app/api/v1/services/[serviceId]/configuration/route.ts similarity index 66% rename from web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/configuration/route.ts rename to web/app/api/v1/services/[serviceId]/configuration/route.ts index b4e71e9b..4f5c8771 100644 --- a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/configuration/route.ts +++ b/web/app/api/v1/services/[serviceId]/configuration/route.ts @@ -1,4 +1,4 @@ export { getConfiguration as GET, - patchConfigurationRoute as PATCH, + putConfigurationRoute as PUT, } from "@/lib/public-api-routes"; diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/deploy/route.ts b/web/app/api/v1/services/[serviceId]/deploy/route.ts similarity index 100% rename from web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/deploy/route.ts rename to web/app/api/v1/services/[serviceId]/deploy/route.ts diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/logs/route.ts b/web/app/api/v1/services/[serviceId]/logs/route.ts similarity index 100% rename from web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/logs/route.ts rename to web/app/api/v1/services/[serviceId]/logs/route.ts diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/metrics/route.ts b/web/app/api/v1/services/[serviceId]/metrics/route.ts similarity index 100% rename from web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/metrics/route.ts rename to web/app/api/v1/services/[serviceId]/metrics/route.ts diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/revisions/route.ts b/web/app/api/v1/services/[serviceId]/revisions/route.ts similarity index 100% rename from web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/revisions/route.ts rename to web/app/api/v1/services/[serviceId]/revisions/route.ts diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/logs/route.ts b/web/app/api/v1/services/[serviceId]/rollouts/[rolloutId]/logs/route.ts similarity index 100% rename from web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/logs/route.ts rename to web/app/api/v1/services/[serviceId]/rollouts/[rolloutId]/logs/route.ts diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/route.ts b/web/app/api/v1/services/[serviceId]/rollouts/[rolloutId]/route.ts similarity index 100% rename from web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/route.ts rename to web/app/api/v1/services/[serviceId]/rollouts/[rolloutId]/route.ts diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/route.ts b/web/app/api/v1/services/[serviceId]/rollouts/route.ts similarity index 100% rename from web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/route.ts rename to web/app/api/v1/services/[serviceId]/rollouts/route.ts diff --git a/web/app/api/v1/services/[serviceId]/route.ts b/web/app/api/v1/services/[serviceId]/route.ts new file mode 100644 index 00000000..b21aff2d --- /dev/null +++ b/web/app/api/v1/services/[serviceId]/route.ts @@ -0,0 +1 @@ +export { getServiceDetails as GET } from "@/lib/public-api-routes"; diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/status/route.ts b/web/app/api/v1/services/[serviceId]/status/route.ts similarity index 100% rename from web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/status/route.ts rename to web/app/api/v1/services/[serviceId]/status/route.ts diff --git a/web/components/auth/register-page.tsx b/web/components/auth/register-page.tsx index a2a8966d..3eae2be0 100644 --- a/web/components/auth/register-page.tsx +++ b/web/components/auth/register-page.tsx @@ -31,20 +31,24 @@ export function RegisterPage() { setError(""); setLoading(true); - const { error } = await signUp.email({ - name, - email, - password, - }); + try { + const { error } = await signUp.email({ + name, + email, + password, + }); - setLoading(false); + if (error) { + setError(error.message || "Failed to create account"); + return; + } - if (error) { - setError(error.message || "Failed to create account"); - return; + router.push(redirectTo); + } catch { + setError("Failed to create account"); + } finally { + setLoading(false); } - - router.push(redirectTo); } return ( diff --git a/web/components/auth/sign-in-page.tsx b/web/components/auth/sign-in-page.tsx index bad5a027..00c5555d 100644 --- a/web/components/auth/sign-in-page.tsx +++ b/web/components/auth/sign-in-page.tsx @@ -41,26 +41,30 @@ export function SignInPage() { setError(""); setLoading(true); - const response = await signIn.email({ - email, - password, - }); + try { + const response = await signIn.email({ + email, + password, + }); - setLoading(false); + if (response.error) { + setError(response.error.message || "Failed to sign in"); + return; + } - if (response.error) { - setError(response.error.message || "Failed to sign in"); - return; - } + if ( + (response.data as { twoFactorRedirect?: boolean } | null) + ?.twoFactorRedirect + ) { + return; + } - if ( - (response.data as { twoFactorRedirect?: boolean } | null) - ?.twoFactorRedirect - ) { - return; + router.push(redirectTo); + } catch { + setError("Failed to sign in"); + } finally { + setLoading(false); } - - router.push(redirectTo); } if (isPending || session) { diff --git a/web/components/auth/two-factor-challenge-page.tsx b/web/components/auth/two-factor-challenge-page.tsx index c5aa5e95..cfdf4026 100644 --- a/web/components/auth/two-factor-challenge-page.tsx +++ b/web/components/auth/two-factor-challenge-page.tsx @@ -53,32 +53,36 @@ export function TwoFactorChallengePage() { setError(""); setLoading(true); - const response = - mode === "totp" - ? await authClient.twoFactor.verifyTotp({ - code: normalizedCode, - trustDevice, - }) - : await authClient.twoFactor.verifyBackupCode({ - code: normalizedCode, - trustDevice, - }); + try { + const response = + mode === "totp" + ? await authClient.twoFactor.verifyTotp({ + code: normalizedCode, + trustDevice, + }) + : await authClient.twoFactor.verifyBackupCode({ + code: normalizedCode, + trustDevice, + }); - setLoading(false); + if (response.error) { + setError( + getAuthErrorMessage( + response.error, + mode === "totp" + ? "Invalid authenticator code" + : "Invalid backup code", + ), + ); + return; + } - if (response.error) { - setError( - getAuthErrorMessage( - response.error, - mode === "totp" - ? "Invalid authenticator code" - : "Invalid backup code", - ), - ); - return; + router.push(redirectTo); + } catch { + setError("Failed to verify two-factor code"); + } finally { + setLoading(false); } - - router.push(redirectTo); } if (isPending || session) { diff --git a/web/components/cluster/cluster-health-summary.tsx b/web/components/cluster/cluster-health-summary.tsx index 9f4b12e4..9b0563ea 100644 --- a/web/components/cluster/cluster-health-summary.tsx +++ b/web/components/cluster/cluster-health-summary.tsx @@ -1,8 +1,6 @@ "use client"; -import { Activity, Network, Server } from "lucide-react"; import useSWR from "swr"; -import { Card, CardContent } from "@/components/ui/card"; import { fetcher } from "@/lib/fetcher"; type ClusterHealthData = { @@ -18,12 +16,10 @@ type ClusterHealthData = { interface ClusterHealthSummaryProps { initialData: ClusterHealthData; - showHeader?: boolean; } export function ClusterHealthSummary({ initialData, - showHeader = true, }: ClusterHealthSummaryProps) { const { data } = useSWR("/api/cluster-health", fetcher, { fallbackData: initialData, @@ -32,66 +28,55 @@ export function ClusterHealthSummary({ const summary = data?.summary ?? initialData.summary; + const anyOnline = summary.onlineServers > 0; + const stats = [ { - label: "Servers", + label: "servers", value: `${summary.onlineServers}/${summary.totalServers}`, subtitle: "online", - icon: Server, - healthy: summary.onlineServers === summary.totalServers, + healthy: + summary.totalServers > 0 && + summary.onlineServers === summary.totalServers, }, { - label: "Tunnels", + label: "tunnels", value: `${summary.networkHealthy}/${summary.onlineServers}`, subtitle: "connected", - icon: Network, - healthy: summary.networkHealthy === summary.onlineServers, + healthy: anyOnline && summary.networkHealthy === summary.onlineServers, }, { - label: "Runtimes", + label: "runtimes", value: `${summary.containerHealthy}/${summary.onlineServers}`, subtitle: "responsive", - icon: Activity, - healthy: summary.containerHealthy === summary.onlineServers, + healthy: anyOnline && summary.containerHealthy === summary.onlineServers, }, ]; + const degraded = stats.filter((stat) => !stat.healthy); + + if (degraded.length === 0) { + return ( +
+ + All systems operational +
+ ); + } + return ( -
- {showHeader && ( -
-

Cluster Health

-

- Real-time infrastructure status -

+
+ {degraded.map((stat) => ( +
+ + + {stat.value} + + + {stat.label} {stat.subtitle} +
- )} -
- {stats.map((stat) => ( - - -
- -
-
-

{stat.label}

-

- {stat.value} - - {stat.subtitle} - -

-
-
-
- ))} -
+ ))}
); } diff --git a/web/components/core/status-indicator.tsx b/web/components/core/status-indicator.tsx index 325602ed..cf581ab6 100644 --- a/web/components/core/status-indicator.tsx +++ b/web/components/core/status-indicator.tsx @@ -17,7 +17,13 @@ const STATUS_COLORS: Record = { }, }; -export function StatusIndicator({ status }: { status: string }) { +export function StatusIndicator({ + status, + showLabel = false, +}: { + status: string; + showLabel?: boolean; +}) { const color = STATUS_COLORS[status] || STATUS_COLORS.unknown; return ( @@ -32,6 +38,13 @@ export function StatusIndicator({ status }: { status: string }) { className={`relative inline-flex rounded-full h-2 w-2 ${color.dot}`} /> + {showLabel && ( + + {status} + + )}
); } diff --git a/web/components/core/summary-card.tsx b/web/components/core/summary-card.tsx new file mode 100644 index 00000000..6d0990b1 --- /dev/null +++ b/web/components/core/summary-card.tsx @@ -0,0 +1,70 @@ +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +export const SUMMARY_CARD_MIN_HEIGHT = 148; + +export const SUMMARY_CARD_CLASSNAME = + "group flex w-full flex-col rounded-xl border border-slate-200 dark:border-slate-700 bg-white/50 dark:bg-slate-900/50 px-3.5 py-3 transition-all duration-200 hover:ring hover:ring-primary/25 dark:hover:ring-primary/55"; + +export function SummaryCardTitle({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +

+ {children} +

+ ); +} + +export function SummaryCardLine({ + icon: Icon, + value, +}: { + icon: LucideIcon; + value: ReactNode; +}) { + return ( +
+ + + {value} + +
+ ); +} + +export function SummaryCardStat({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+ + {label} + + + {children} +
+ ); +} + +export function SummaryCardValue({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/components/github/github-repo-selector.tsx b/web/components/github/github-repo-selector.tsx index 5ad14e4d..173afb79 100644 --- a/web/components/github/github-repo-selector.tsx +++ b/web/components/github/github-repo-selector.tsx @@ -4,6 +4,7 @@ import { Globe, Loader2, Lock } from "lucide-react"; import { useMemo, useState } from "react"; import useSWR from "swr"; import { Input } from "@/components/ui/input"; +import { fetcher } from "@/lib/fetcher"; type GitHubRepo = { id: number; @@ -22,8 +23,6 @@ type SelectedRepo = { installationId?: number; }; -const fetcher = (url: string) => fetch(url).then((res) => res.json()); - const EMPTY_REPOS: GitHubRepo[] = []; export function GitHubRepoSelector({ @@ -37,7 +36,7 @@ export function GitHubRepoSelector({ }) { const [search, setSearch] = useState(""); - const { data, isLoading } = useSWR<{ + const { data, error, isLoading } = useSWR<{ repos: GitHubRepo[]; installations: Array<{ id: number; @@ -62,9 +61,9 @@ export function GitHubRepoSelector({ const alreadyInList = repos.some( (r) => r.fullName.toLowerCase() === repoName.toLowerCase(), ); - if (alreadyInList) return null; + if (!error && alreadyInList) return null; return repoName; - }, [search, repos]); + }, [error, search, repos]); const handleSelect = (repo: GitHubRepo) => { onChange({ @@ -122,6 +121,22 @@ export function GitHubRepoSelector({ disabled={disabled} />
+ {publicRepoFromSearch && ( +
+

+ Public Repository +

+ +
+ )} + {isLoading ? (
@@ -129,19 +144,14 @@ export function GitHubRepoSelector({
) : ( <> - {publicRepoFromSearch && ( -
-

- Public Repository -

- + {error && ( +
+ {error instanceof Error + ? error.message + : "Failed to load GitHub repositories"}
)} @@ -170,7 +180,7 @@ export function GitHubRepoSelector({
)} - {!hasInstallations && !publicRepoFromSearch && ( + {!error && !hasInstallations && !publicRepoFromSearch && (

No GitHub App installed.

@@ -180,7 +190,8 @@ export function GitHubRepoSelector({

)} - {hasInstallations && + {!error && + hasInstallations && filteredRepos.length === 0 && !publicRepoFromSearch && (
diff --git a/web/components/logs/log-viewer.tsx b/web/components/logs/log-viewer.tsx index 852b0d46..85c7ec18 100644 --- a/web/components/logs/log-viewer.tsx +++ b/web/components/logs/log-viewer.tsx @@ -1071,6 +1071,7 @@ export function LogViewer(props: LogViewerProps) {
); diff --git a/web/components/service/details/pending-changes-banner.tsx b/web/components/service/details/pending-changes-banner.tsx index 4a48a802..e2286a0f 100644 --- a/web/components/service/details/pending-changes-banner.tsx +++ b/web/components/service/details/pending-changes-banner.tsx @@ -3,6 +3,7 @@ import { Rocket } from "lucide-react"; import { useRouter } from "next/navigation"; import { memo, useState } from "react"; +import { toast } from "sonner"; import { useSWRConfig } from "swr"; import { triggerBuild } from "@/actions/builds"; import { deployService } from "@/actions/projects"; @@ -52,17 +53,32 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({ try { if (shouldBuild) { await triggerBuild(service.id); - router.push( - `/dashboard/projects/${projectSlug}/${envName}/services/${service.id}/builds`, - ); } else { await deployService(service.id); - await mutate(`/api/services/${service.id}/rollouts`); } - onUpdate(); + } catch (error) { + toast.error( + error instanceof Error && error.message + ? error.message + : shouldBuild + ? "Failed to start build" + : "Failed to deploy service", + ); + return; } finally { setIsDeploying(false); } + + if (shouldBuild) { + router.push( + `/dashboard/projects/${projectSlug}/${envName}/services/${service.id}/builds`, + ); + } else { + await mutate(`/api/services/${service.id}/rollouts`).catch( + () => undefined, + ); + } + onUpdate(); }; return ( diff --git a/web/components/service/details/replicas-section.tsx b/web/components/service/details/replicas-section.tsx index ce54091d..80cec112 100644 --- a/web/components/service/details/replicas-section.tsx +++ b/web/components/service/details/replicas-section.tsx @@ -19,6 +19,7 @@ import type { Server as ServerType, ServiceWithDetails as Service, } from "@/db/types"; +import { fetcher as fetchJson } from "@/lib/fetcher"; type ServerInfo = Pick< ServerType, @@ -27,8 +28,7 @@ type ServerInfo = Pick< type PlacementMode = "manual" | "automatic"; const fetcher = async (url: string): Promise => { - const res = await fetch(url); - const servers: ServerInfo[] = await res.json(); + const servers = await fetchJson(url); return servers.map(({ id, name, isProxy, status, wireguardIp }) => ({ id, name, @@ -47,7 +47,9 @@ export const ReplicasSection = memo(function ReplicasSection({ service: Service; onUpdate: () => void; }) { - const { data: servers, isLoading } = useSWR(SERVERS_URL, fetcher); + const { data: servers, error, isLoading } = useSWR(SERVERS_URL, fetcher); + const loadError = + error instanceof Error ? error.message : "Failed to load servers"; const [localReplicas, setLocalReplicas] = useState>( {}, ); @@ -282,6 +284,14 @@ export const ReplicasSection = memo(function ReplicasSection({
+ ) : error ? ( + + + + + Failed to load servers + {loadError} + ) : !servers || servers.length === 0 ? ( @@ -438,6 +448,14 @@ export const ReplicasSection = memo(function ReplicasSection({
+ ) : error ? ( + + + + + Failed to load servers + {loadError} + ) : !servers || servers.length === 0 ? ( diff --git a/web/components/service/details/secrets-section.tsx b/web/components/service/details/secrets-section.tsx index c45fba39..27040bb4 100644 --- a/web/components/service/details/secrets-section.tsx +++ b/web/components/service/details/secrets-section.tsx @@ -221,6 +221,7 @@ export const SecretsSection = memo(function SecretsSection({ type="button" onClick={() => handleReveal(secret.id)} disabled={revealingSecretId === secret.id} + aria-label={`${revealedSecrets[secret.id] ? "Hide" : "Show"} ${secret.key} value`} className="text-muted-foreground hover:text-foreground disabled:opacity-50" > {revealedSecrets[secret.id] ? ( @@ -242,6 +243,7 @@ export const SecretsSection = memo(function SecretsSection({ - )} -
- + {hasChanges && ( + + )} +
); -}); +} diff --git a/web/components/service/service-canvas.tsx b/web/components/service/service-canvas.tsx index 0a02ad29..125a451d 100644 --- a/web/components/service/service-canvas.tsx +++ b/web/components/service/service-canvas.tsx @@ -6,8 +6,6 @@ import { Github, Globe, HardDrive, - Lock, - Network, Settings, Trash2, Upload, @@ -17,6 +15,13 @@ import { useRouter } from "next/navigation"; import type { AnchorHTMLAttributes, MouseEvent, PointerEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; +import { + SUMMARY_CARD_MIN_HEIGHT, + SummaryCardLine, + SummaryCardStat, + SummaryCardTitle, + SummaryCardValue, +} from "@/components/core/summary-card"; import { buttonVariants } from "@/components/ui/button"; import { getStatusColorFromDeployments } from "@/components/ui/canvas-wrapper"; import { @@ -41,7 +46,10 @@ import { NativeSelectOption, } from "@/components/ui/native-select"; import type { Environment, ServiceWithDetails } from "@/db/types"; -import { observedReadyPhases } from "@/lib/deployment-status"; +import { + observedReadyPhases, + observedStartingPhases, +} from "@/lib/deployment-status"; import { fetcher } from "@/lib/fetcher"; import { cn } from "@/lib/utils"; import { @@ -56,7 +64,7 @@ type CanvasPosition = { }; const SERVICE_CARD_WIDTH = 320; -const SERVICE_CARD_HEIGHT = 150; +const SERVICE_CARD_HEIGHT = SUMMARY_CARD_MIN_HEIGHT; const SERVICE_CARD_GAP_X = 56; const SERVICE_CARD_GAP_Y = 48; const DEFAULT_GRID_COLUMNS = 3; @@ -68,6 +76,36 @@ const SNAP_GRID_SIZE = 24; const CANVAS_DOT_PATTERN = "radial-gradient(circle, color-mix(in oklab, var(--muted-foreground) 36%, transparent) 1px, transparent 1px)"; +function getStatusLabel( + deployments: ServiceWithDetails["deployments"], + runningCount: number, +): string { + if (deployments.length === 0) { + return "not deployed"; + } + if (deployments.some((d) => d.observedPhase === "failed")) { + return "failed"; + } + if (runningCount === deployments.length) { + return "running"; + } + if ( + deployments.some((d) => + (observedStartingPhases as readonly string[]).includes(d.observedPhase), + ) + ) { + return "deploying"; + } + if (deployments.every((d) => d.observedPhase === "sleeping")) { + return "sleeping"; + } + if (runningCount === 0) { + return "stopped"; + } + + return "degraded"; +} + function getCanvasScale() { if (typeof window === "undefined") { return 1; @@ -137,27 +175,19 @@ function getServicePosition( function ServiceCardSkeleton() { return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
@@ -288,130 +318,79 @@ function ServiceCard({ service, projectSlug, envName, - edgeDomain, dragHandleProps, }: { service: ServiceWithDetails; projectSlug: string; envName: string; - edgeDomain: string | null; dragHandleProps?: AnchorHTMLAttributes; }) { const colors = getStatusColorFromDeployments(service.deployments); const { className: dragHandleClassName, ...linkProps } = dragHandleProps ?? {}; - const publicPorts = service.ports.filter((p) => p.isPublic && p.domain); - const tcpUdpPorts = service.ports.filter( - (p) => - (p.protocol === "tcp" || p.protocol === "udp") && - p.isPublic && - p.externalPort, - ); - const hasInternalDns = service.deployments.some((d) => - (observedReadyPhases as readonly string[]).includes(d.observedPhase), - ); const runningCount = service.deployments.filter((d) => (observedReadyPhases as readonly string[]).includes(d.observedPhase), ).length; - - const hasEndpoints = - publicPorts.length > 0 || - (tcpUdpPorts.length > 0 && edgeDomain) || - hasInternalDns; + const statusLabel = getStatusLabel(service.deployments, runningCount); + const publicEndpoint = service.ports.find((p) => p.isPublic && p.domain); + const volumeNames = (service.volumes ?? []).map((v) => v.name).join(", "); return ( -
+
-
-
-
-

- {service.name} -

-
- +
+ {service.name} + + {(publicEndpoint || volumeNames) && ( +
+ {publicEndpoint?.domain && ( + + )} + {volumeNames && ( + + )} +
+ )} + +
+ + + {service.deployments.length > 0 + ? `${runningCount}/${service.deployments.length}` + : "0"} + + + + + {runningCount > 0 && ( )} - - {service.deployments.length > 0 - ? `${runningCount}/${service.deployments.length}` - : "Not deployed"} - -
-
-
- - {hasEndpoints && ( -
- {publicPorts.map((port) => ( -
-
- - {port.domain} -
-
- ))} - {tcpUdpPorts.length > 0 && - edgeDomain && - tcpUdpPorts.map((port) => ( -
-
- - - {port.protocol}://{edgeDomain}:{port.externalPort} - -
-
- ))} - {hasInternalDns && ( -
-
- - - {service.hostname || service.name}.internal - -
-
- )} -
- )} -
- - {service.volumes && service.volumes.length > 0 && ( -
- {service.volumes.map((volume) => ( -
- - - {volume.name} + {statusLabel} -
- ))} + +
- )} +
); @@ -422,7 +401,6 @@ function DraggableServiceCard({ index, projectSlug, envName, - edgeDomain, canvasScale, onPositionChange, }: { @@ -430,7 +408,6 @@ function DraggableServiceCard({ index: number; projectSlug: string; envName: string; - edgeDomain: string | null; canvasScale: number; onPositionChange: (serviceId: string, position: CanvasPosition) => void; }) { @@ -551,7 +528,6 @@ function DraggableServiceCard({ service={service} projectSlug={projectSlug} envName={envName} - edgeDomain={edgeDomain} dragHandleProps={{ className: "touch-none cursor-grab select-none active:cursor-grabbing", @@ -572,13 +548,11 @@ export function ServiceCanvas({ projectSlug, envId, envName, - edgeDomain, }: { projectId: string; projectSlug: string; envId: string; envName: string; - edgeDomain: string | null; }) { const { data: environments } = useSWR( `/api/projects/${projectId}/environments`, @@ -828,7 +802,6 @@ export function ServiceCanvas({ service={service} projectSlug={projectSlug} envName={envName} - edgeDomain={edgeDomain} /> ))}
@@ -881,7 +854,6 @@ export function ServiceCanvas({ index={index} projectSlug={projectSlug} envName={envName} - edgeDomain={edgeDomain} canvasScale={canvasScale} onPositionChange={handlePositionChange} /> diff --git a/web/db/queries.ts b/web/db/queries.ts index 6633cc0a..b3277037 100644 --- a/web/db/queries.ts +++ b/web/db/queries.ts @@ -1,4 +1,12 @@ -import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import { + and, + count, + countDistinct, + eq, + inArray, + isNotNull, + isNull, +} from "drizzle-orm"; import { cache } from "react"; import { db } from "@/db"; import { @@ -14,6 +22,7 @@ import type { ControlPlaneUpdateState, ControlPlaneUpgradeState, } from "@/lib/control-plane-updates"; +import { observedReadyPhases } from "@/lib/deployment-status"; import type { EmailAlertsConfig, SmtpConfig, @@ -30,27 +39,50 @@ import { } from "@/lib/victoria-metrics"; export async function listProjects() { - const projectList = await db - .select() - .from(projects) - .orderBy(projects.createdAt); - - const projectsWithCounts = await Promise.all( - projectList.map(async (project) => { - const serviceCount = await db - .select({ count: services.id }) + const [projectList, serviceCounts, onlineCounts, environmentCounts] = + await Promise.all([ + db.select().from(projects).orderBy(projects.createdAt), + db + .select({ projectId: services.projectId, total: count() }) .from(services) + .where(isNull(services.deletedAt)) + .groupBy(services.projectId), + db + .select({ + projectId: services.projectId, + online: countDistinct(services.id), + }) + .from(services) + .innerJoin(deployments, eq(deployments.serviceId, services.id)) .where( - and(eq(services.projectId, project.id), isNull(services.deletedAt)), - ); - return { - ...project, - serviceCount: serviceCount.length, - }; - }), + and( + isNull(services.deletedAt), + inArray(deployments.observedPhase, [...observedReadyPhases]), + ), + ) + .groupBy(services.projectId), + db + .select({ projectId: environments.projectId, total: count() }) + .from(environments) + .groupBy(environments.projectId), + ]); + + const totalByProject = new Map( + serviceCounts.map((row) => [row.projectId, row.total]), + ); + const onlineByProject = new Map( + onlineCounts.map((row) => [row.projectId, row.online]), + ); + const environmentsByProject = new Map( + environmentCounts.map((row) => [row.projectId, row.total]), ); - return projectsWithCounts; + return projectList.map((project) => ({ + ...project, + serviceCount: totalByProject.get(project.id) ?? 0, + onlineServiceCount: onlineByProject.get(project.id) ?? 0, + environmentCount: environmentsByProject.get(project.id) ?? 0, + })); } export async function getProject(id: string) { diff --git a/web/lib/inngest/functions/migration-workflow.ts b/web/lib/inngest/functions/migration-workflow.ts index 6ac80c79..9f1a6cc0 100644 --- a/web/lib/inngest/functions/migration-workflow.ts +++ b/web/lib/inngest/functions/migration-workflow.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { db } from "@/db"; import { getBackupStorageConfig } from "@/db/queries"; import { @@ -301,26 +301,31 @@ export const migrationWorkflow = inngest.createFunction( } await step.run("deploy-target", async () => { - await db - .update(services) - .set({ migrationStatus: "deploying_target" }) - .where(eq(services.id, serviceId)); + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, + ); + await tx + .update(services) + .set({ migrationStatus: "deploying_target" }) + .where(eq(services.id, serviceId)); - await db - .delete(serviceReplicas) - .where(eq(serviceReplicas.serviceId, serviceId)); - - await db.insert(serviceReplicas).values({ - id: randomUUID(), - serviceId, - serverId: targetServerId, - count: 1, - }); + await tx + .delete(serviceReplicas) + .where(eq(serviceReplicas.serviceId, serviceId)); - await db - .update(services) - .set({ lockedServerId: targetServerId }) - .where(eq(services.id, serviceId)); + await tx.insert(serviceReplicas).values({ + id: randomUUID(), + serviceId, + serverId: targetServerId, + count: 1, + }); + + await tx + .update(services) + .set({ lockedServerId: targetServerId }) + .where(eq(services.id, serviceId)); + }); await deployServiceInternal(serviceId, actor, { runtimeBaseRevisionId: sourceServiceRevisionId, diff --git a/web/lib/inngest/functions/service-deletion-workflow.ts b/web/lib/inngest/functions/service-deletion-workflow.ts index 13f4b05e..a7fd41be 100644 --- a/web/lib/inngest/functions/service-deletion-workflow.ts +++ b/web/lib/inngest/functions/service-deletion-workflow.ts @@ -8,6 +8,7 @@ import { isNull, lte, or, + sql, } from "drizzle-orm"; import { cron } from "inngest"; import { db } from "@/db"; @@ -276,17 +277,29 @@ export const serviceDeletionWorkflow = inngest.createFunction( } const deletedAt = new Date(); - await db - .update(services) - .set({ - deletedAt, - purgeAfter: addUtcDays(deletedAt, DELETED_SERVICE_RETENTION_DAYS), - originalHostname: setup.service.hostname, - hostname: null, - deletionStatus: null, - deletionError: null, - }) - .where(eq(services.id, serviceId)); + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, + ); + const current = await tx + .select({ hostname: services.hostname }) + .from(services) + .where(eq(services.id, serviceId)) + .limit(1) + .then((rows) => rows[0]); + if (!current) throw new Error("Service not found"); + await tx + .update(services) + .set({ + deletedAt, + purgeAfter: addUtcDays(deletedAt, DELETED_SERVICE_RETENTION_DAYS), + originalHostname: current.hostname, + hostname: null, + deletionStatus: null, + deletionError: null, + }) + .where(eq(services.id, serviceId)); + }); }); return { status: "deleted", serviceId, backupIds }; @@ -476,18 +489,23 @@ export const serviceRestoreWorkflow = inngest.createFunction( const deployResult = await step.run( "start-restored-deployment", async () => { - await db - .update(services) - .set({ - deletedAt: null, - purgeAfter: null, - hostname: setup.service.originalHostname, - originalHostname: null, - deletionStatus: "restoring", - deletionError: null, - lockedServerId: setup.targetServerId, - }) - .where(eq(services.id, serviceId)); + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, + ); + await tx + .update(services) + .set({ + deletedAt: null, + purgeAfter: null, + hostname: setup.service.originalHostname, + originalHostname: null, + deletionStatus: "restoring", + deletionError: null, + lockedServerId: setup.targetServerId, + }) + .where(eq(services.id, serviceId)); + }); try { const result = await deployServiceInternal(serviceId, actor, { @@ -498,20 +516,25 @@ export const serviceRestoreWorkflow = inngest.createFunction( } return result; } catch (error) { - await db - .update(services) - .set({ - deletedAt: toDate(setup.service.deletedAt), - purgeAfter: toDate(setup.service.purgeAfter), - hostname: null, - originalHostname: setup.service.originalHostname, - deletionStatus: "failed", - deletionError: - error instanceof Error - ? error.message - : "Restore deployment failed", - }) - .where(eq(services.id, serviceId)); + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, + ); + await tx + .update(services) + .set({ + deletedAt: toDate(setup.service.deletedAt), + purgeAfter: toDate(setup.service.purgeAfter), + hostname: null, + originalHostname: setup.service.originalHostname, + deletionStatus: "failed", + deletionError: + error instanceof Error + ? error.message + : "Restore deployment failed", + }) + .where(eq(services.id, serviceId)); + }); throw error; } }, @@ -552,19 +575,24 @@ export const serviceRestoreWorkflow = inngest.createFunction( if (!healthyDeployment || failedDeployment) { await step.run("mark-restore-deployment-failed", async () => { - await db - .update(services) - .set({ - deletedAt: toDate(setup.service.deletedAt), - purgeAfter: toDate(setup.service.purgeAfter), - hostname: null, - originalHostname: setup.service.originalHostname, - deletionStatus: "failed", - deletionError: - failedDeployment?.failedStage || - "Restore deployment did not become healthy", - }) - .where(eq(services.id, serviceId)); + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, + ); + await tx + .update(services) + .set({ + deletedAt: toDate(setup.service.deletedAt), + purgeAfter: toDate(setup.service.purgeAfter), + hostname: null, + originalHostname: setup.service.originalHostname, + deletionStatus: "failed", + deletionError: + failedDeployment?.failedStage || + "Restore deployment did not become healthy", + }) + .where(eq(services.id, serviceId)); + }); }); return { status: "failed", reason: "deployment" }; } diff --git a/web/lib/public-api-routes.ts b/web/lib/public-api-routes.ts index 6af35edf..66234516 100644 --- a/web/lib/public-api-routes.ts +++ b/web/lib/public-api-routes.ts @@ -14,12 +14,13 @@ import { METRIC_RANGE_KEYS } from "@/lib/metric-ranges"; import { apiError, badRequest, - configurationPatchSchema, - findNestedService, + findServiceContext, isPublicApiDomainError, notFound, - patchConfiguration, + planConfiguration, publicApiDomainResponse, + replaceConfiguration, + replaceConfigurationSchema, resolvePersistedSource, safeConfiguration, } from "@/lib/public-api"; @@ -42,24 +43,25 @@ import { import { isMetricsEnabled, queryServiceMetrics } from "@/lib/victoria-metrics"; export type PublicServiceParams = { - projectId: string; - environmentId: string; serviceId: string; }; export type PublicServiceContext = { params: Promise }; const readRoles = ["admin", "developer", "reader"] as const; +export function parseConfigurationIfMatch(value: string | null): string | null { + const match = value?.match(/^"(sha256:[a-f0-9]{64})"$/); + return match?.[1] ?? null; +} + async function readScope(request: Request, context: PublicServiceContext) { const auth = await requireApiKeyRole(request, [...readRoles]); if (!auth.ok) return { response: auth.response }; try { const params = await context.params; - const service = await findNestedService( - params.projectId, - params.environmentId, - params.serviceId, - ); - return service ? { service, params } : { response: notFound() }; + const found = await findServiceContext(params.serviceId); + return found + ? { service: found.service, target: found, params } + : { response: notFound() }; } catch (error) { return { response: internalError(error, "resolve service scope") }; } @@ -70,12 +72,10 @@ async function writeScope(request: Request, context: PublicServiceContext) { if (!auth.ok) return { response: auth.response }; try { const params = await context.params; - const service = await findNestedService( - params.projectId, - params.environmentId, - params.serviceId, - ); - return service ? { service, params, auth } : { response: notFound() }; + const found = await findServiceContext(params.serviceId); + return found + ? { service: found.service, target: found, params, auth } + : { response: notFound() }; } catch (error) { return { response: internalError(error, "resolve service scope") }; } @@ -100,6 +100,47 @@ function internalError(error: unknown, operation: string) { return apiError("Internal server error", "INTERNAL_ERROR", 500); } +export async function getServiceDetails( + request: Request, + context: PublicServiceContext, +) { + const scope = await readScope(request, context); + if ("response" in scope) return scope.response; + try { + const configuration = await safeConfiguration(scope.service); + const current = configuration.current; + return Response.json({ + target: { + project: { id: scope.target.projectId, slug: scope.target.projectSlug }, + environment: { + id: scope.target.environmentId, + name: scope.target.environmentName, + }, + service: { id: scope.service.id, name: scope.service.name }, + }, + service: { + id: scope.service.id, + name: scope.service.name, + source: current.source, + hostname: current.hostname, + ports: current.ports, + replicas: current.replicas, + placement: + current.placement.mode === "manual" + ? { mode: "manual", servers: current.placements } + : current.placement, + healthCheck: current.healthCheck, + startCommand: current.startCommand, + resources: + current.resources.cpuCores == null ? null : current.resources, + }, + management: configuration.management, + }); + } catch (error) { + return internalError(error, "read service"); + } +} + export async function getConfiguration( request: Request, context: PublicServiceContext, @@ -113,13 +154,13 @@ export async function getConfiguration( } } -export async function patchConfigurationRoute( +export async function putConfigurationRoute( request: Request, context: PublicServiceContext, ) { const scope = await writeScope(request, context); if ("response" in scope) return scope.response; - const parsed = configurationPatchSchema.safeParse( + const parsed = replaceConfigurationSchema.safeParse( await request.json().catch(() => null), ); if (!parsed.success) { @@ -127,12 +168,69 @@ export async function patchConfigurationRoute( parsed.error.issues[0]?.message ?? "Invalid configuration", ); } + const expectedVersion = parseConfigurationIfMatch( + request.headers.get("if-match"), + ); + if (!expectedVersion) { + return badRequest("A valid If-Match configuration version is required"); + } try { - return Response.json(await patchConfiguration(scope.service, parsed.data)); + const result = await replaceConfiguration( + scope.service, + parsed.data, + expectedVersion, + ); + return Response.json({ + target: { + project: { id: scope.target.projectId, slug: scope.target.projectSlug }, + environment: { + id: scope.target.environmentId, + name: scope.target.environmentName, + }, + service: { id: scope.service.id, name: parsed.data.name }, + }, + ...result, + }); } catch (error) { return isPublicApiDomainError(error) ? publicApiDomainResponse(error) - : internalError(error, "patch configuration"); + : internalError(error, "replace configuration"); + } +} + +export async function postConfigurationPlanRoute( + request: Request, + context: PublicServiceContext, +) { + const scope = await writeScope(request, context); + if ("response" in scope) return scope.response; + const parsed = replaceConfigurationSchema.safeParse( + await request.json().catch(() => null), + ); + if (!parsed.success) + return badRequest( + parsed.error.issues[0]?.message ?? "Invalid configuration", + ); + try { + const { targetServiceName, ...plan } = await planConfiguration( + scope.service, + parsed.data, + ); + return Response.json({ + target: { + project: { id: scope.target.projectId, slug: scope.target.projectSlug }, + environment: { + id: scope.target.environmentId, + name: scope.target.environmentName, + }, + service: { id: scope.service.id, name: targetServiceName }, + }, + ...plan, + }); + } catch (error) { + return isPublicApiDomainError(error) + ? publicApiDomainResponse(error) + : internalError(error, "plan configuration"); } } @@ -211,6 +309,14 @@ export async function getStatus( resolvePersistedSource(scope.service), ]); return Response.json({ + target: { + project: { id: scope.target.projectId, slug: scope.target.projectSlug }, + environment: { + id: scope.target.environmentId, + name: scope.target.environmentName, + }, + service: { id: scope.service.id, name: scope.service.name }, + }, service: { id: scope.service.id, name: scope.service.name, @@ -435,6 +541,14 @@ export async function getServiceLogs( if ("response" in scope) return scope.response; if (!isLoggingEnabled()) { return Response.json({ + target: { + project: { id: scope.target.projectId, slug: scope.target.projectSlug }, + environment: { + id: scope.target.environmentId, + name: scope.target.environmentName, + }, + service: { id: scope.service.id, name: scope.service.name }, + }, provider: "disabled", logs: [], nextCursor: null, @@ -467,6 +581,14 @@ export async function getServiceLogs( }) : await query(); return Response.json({ + target: { + project: { id: scope.target.projectId, slug: scope.target.projectSlug }, + environment: { + id: scope.target.environmentId, + name: scope.target.environmentName, + }, + service: { id: scope.service.id, name: scope.service.name }, + }, provider: "enabled", logs: result.logs.map(publicServiceLog), nextCursor: nextServiceLogCursor(result.logs, options.rawCursor), diff --git a/web/lib/public-api.ts b/web/lib/public-api.ts index 0d722a85..6bc13d87 100644 --- a/web/lib/public-api.ts +++ b/web/lib/public-api.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { and, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/db"; @@ -15,6 +15,7 @@ import { serviceVolumes, } from "@/db/schema"; import { validateDockerImageInternal } from "@/lib/docker-image"; +import { nameSchema } from "@/lib/schemas"; import { getServiceTotalReplicas } from "@/lib/service-config"; import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; import { @@ -110,7 +111,7 @@ export const publicSourceSchema = z.discriminatedUnion("type", [ type: z.literal("github"), repository: githubRepositorySchema, branch: z.string().trim().min(1).max(255), - rootDir: rootDirSchema.nullable().optional(), + rootDir: rootDirSchema.nullable(), }), ]); @@ -120,7 +121,7 @@ export type PublicSource = type: "github"; repository: string | null; branch: string; - rootDir?: string; + rootDir: string | null; }; export type NestedService = typeof services.$inferSelect; type GitHubRepo = typeof githubRepos.$inferSelect; @@ -161,9 +162,7 @@ export function resolvePersistedSourceFromRows( repo?.defaultBranch?.trim() || service.githubBranch?.trim() || "main", - ...(service.githubRootDir?.trim() - ? { rootDir: service.githubRootDir.trim() } - : {}), + rootDir: service.githubRootDir?.trim() || null, }; } @@ -182,33 +181,27 @@ export async function resolvePersistedSource( return resolvePersistedSourceFromRows(service, repo); } -export async function findNestedService( - projectId: string, - environmentId: string, - serviceId: string, -) { - const row = await db - .select({ service: services }) - .from(projects) +export async function findServiceContext(serviceId: string) { + return db + .select({ + service: services, + projectId: projects.id, + projectSlug: projects.slug, + environmentId: environments.id, + environmentName: environments.name, + }) + .from(services) + .innerJoin(projects, eq(projects.id, services.projectId)) .innerJoin( environments, and( - eq(environments.id, environmentId), + eq(environments.id, services.environmentId), eq(environments.projectId, projects.id), ), ) - .innerJoin( - services, - and( - eq(services.id, serviceId), - eq(services.projectId, projects.id), - eq(services.environmentId, environments.id), - isNull(services.deletedAt), - ), - ) - .where(eq(projects.id, projectId)) - .limit(1); - return row[0]?.service ?? null; + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .limit(1) + .then((rows) => rows[0] ?? null); } export function apiError(message: string, code: string, status: number) { @@ -296,7 +289,7 @@ function sanitizeSpec(specification: unknown) { type: "github" as const, repository: spec.source.repository, branch: spec.source.branch, - ...(spec.source.rootDir ? { rootDir: spec.source.rootDir } : {}), + rootDir: spec.source.rootDir, } : { type: "image" as const, image: spec.source.image }, hostname: spec.hostname, @@ -395,7 +388,9 @@ export async function safeConfiguration(service: NestedService) { }; const current = { source, - hostname: service.hostname, + hostname: + service.hostname?.trim() || + getDefaultServiceHostname(service.name, service.id), stateful: service.stateful, replicas: replicaCount, placements: sortedPlacements, @@ -459,8 +454,7 @@ export async function safeConfiguration(service: NestedService) { const comparableCurrent = { source: current.source, - hostname: - current.hostname?.trim() || getDefaultServiceHostname(service.name), + hostname: current.hostname, stateful: current.stateful, placement: current.placement.mode === "automatic" @@ -592,13 +586,14 @@ export const placementSchema = z.discriminatedUnion("mode", [ }); }), ]); -export const configurationPatchSchema = z.strictObject({ - source: publicSourceSchema.optional(), - hostname: hostnameSchema.nullable().optional(), - ports: z.array(portSchema).max(100).optional(), - placement: placementSchema.optional(), - healthCheck: healthCheckSchema.nullable().optional(), - startCommand: z.string().trim().min(1).max(4096).nullable().optional(), +export const replaceConfigurationSchema = z.strictObject({ + name: nameSchema, + source: publicSourceSchema, + hostname: hostnameSchema, + ports: z.array(portSchema).max(100), + placement: placementSchema, + healthCheck: healthCheckSchema.nullable(), + startCommand: z.string().trim().min(1).max(4096).nullable(), resources: z .strictObject({ cpuCores: z.number().min(0.1).max(64).nullable(), @@ -608,7 +603,7 @@ export const configurationPatchSchema = z.strictObject({ (value) => (value.cpuCores === null) === (value.memoryMb === null), "CPU and memory limits must both be set or both be null", ) - .optional(), + .nullable(), }); type PublicApiDomainError = Error & { code: string; status: number }; @@ -644,15 +639,185 @@ function healthCheckFromService(service: NestedService) { : null; } -export async function patchConfiguration( +type ReplacementInput = z.infer; +type ConfigurationChange = { field: string; from: unknown; to: unknown }; + +function canonicalPlanSource(source: PublicSource) { + return source.type === "github" + ? { + ...source, + repository: source.repository?.toLowerCase() ?? null, + } + : source; +} + +function canonicalReplacementState( + service: NestedService, + source: ReturnType, + ports: Array<{ port: number; isPublic: boolean; domain: string | null }>, + placements: Array<{ serverId: string; count: number }>, +) { + const resources = + service.resourceCpuLimit == null && service.resourceMemoryLimitMb == null + ? null + : { + cpuCores: service.resourceCpuLimit, + memoryMb: service.resourceMemoryLimitMb, + }; + return { + name: service.name, + source: canonicalPlanSource(source), + hostname: + service.hostname?.trim() || + getDefaultServiceHostname(service.name, service.id), + ports: ports + .map((port) => ({ + containerPort: port.port, + public: port.isPublic, + domain: port.domain, + })) + .toSorted( + (a, b) => + a.containerPort - b.containerPort || + Number(a.public) - Number(b.public) || + (a.domain ?? "").localeCompare(b.domain ?? "", "en"), + ), + placement: + service.placementMode === "automatic" + ? { mode: "automatic" as const, replicas: service.replicas } + : { + mode: "manual" as const, + placements: placements + .map(({ serverId, count }) => ({ serverId, count })) + .toSorted((a, b) => a.serverId.localeCompare(b.serverId, "en")), + }, + healthCheck: healthCheckFromService(service), + startCommand: service.startCommand?.trim() || null, + resources, + }; +} + +export function canonicalDesired(input: ReplacementInput) { + return { + ...input, + source: canonicalPlanSource(input.source), + ports: input.ports + .map((port) => ({ + ...port, + domain: port.domain ?? null, + })) + .toSorted( + (a, b) => + a.containerPort - b.containerPort || + Number(a.public) - Number(b.public) || + (a.domain ?? "").localeCompare(b.domain ?? "", "en"), + ), + placement: + input.placement.mode === "manual" + ? { + ...input.placement, + placements: input.placement.placements.toSorted((a, b) => + a.serverId.localeCompare(b.serverId, "en"), + ), + } + : input.placement, + }; +} + +function fingerprint(value: unknown) { + return `sha256:${createHash("sha256").update(JSON.stringify(value)).digest("hex")}`; +} + +function configurationChanges( + current: Record, + desired: Record, +) { + const changes: ConfigurationChange[] = []; + const compare = (field: string, from: unknown, to: unknown) => { + if (JSON.stringify(from) === JSON.stringify(to)) return; + if ( + from !== null && + to !== null && + typeof from === "object" && + typeof to === "object" && + !Array.isArray(from) && + !Array.isArray(to) + ) { + for (const key of new Set([ + ...Object.keys(from as Record), + ...Object.keys(to as Record), + ])) + compare( + `${field}.${key}`, + (from as Record)[key], + (to as Record)[key], + ); + return; + } + changes.push({ + field, + from: from === undefined ? null : from, + to: to === undefined ? null : to, + }); + }; + for (const field of Object.keys(desired)) + compare(field, current[field], desired[field]); + return changes; +} + +export function planCanonicalConfiguration( + current: ReturnType, + desiredInput: ReplacementInput, +) { + const canonicalCurrent = { + ...current, + source: canonicalPlanSource(current.source), + }; + const desired = canonicalDesired(desiredInput); + const changes = configurationChanges(canonicalCurrent, desired); + return { + action: changes.length ? ("updated" as const) : ("noop" as const), + currentVersion: fingerprint(canonicalCurrent), + desiredVersion: fingerprint(desired), + changes, + }; +} + +export async function planConfiguration( + service: NestedService, + input: ReplacementInput, +) { + return replaceConfigurationInternal(service, input, null); +} + +export async function replaceConfiguration( + service: NestedService, + input: ReplacementInput, + expectedVersion: string, +) { + const { targetServiceName: _, ...plan } = await replaceConfigurationInternal( + service, + input, + expectedVersion, + ); + return plan; +} + +async function replaceConfigurationInternal( service: NestedService, - input: z.infer, + input: ReplacementInput, + expectedVersion: string | null, ) { - if (input.source?.type === "image" && input.source.image !== service.image) { + let imageValidated = false; + if ( + input.source.type === "image" && + (service.sourceType !== "image" || input.source.image !== service.image) + ) { const validation = await validateDockerImageInternal(input.source.image); if (!validation.valid) { domainError(validation.error || "Invalid image", "INVALID_IMAGE", 400); } + imageValidated = true; } return db.transaction(async (tx) => { @@ -666,7 +831,6 @@ export async function patchConfiguration( .limit(1) .then((rows) => rows[0]); if (!persisted) domainError("Service not found", "NOT_FOUND", 404); - const [ports, volumes, placements, repo] = await Promise.all([ tx .select() @@ -688,8 +852,31 @@ export async function patchConfiguration( .then((rows) => rows[0]), ]); const source = resolvePersistedSourceFromRows(persisted, repo); + const currentState = canonicalReplacementState( + persisted, + source, + ports, + placements, + ); if ( - input.placement?.mode === "automatic" && + input.source.type === "image" && + input.source.image !== persisted.image && + !imageValidated + ) { + domainError( + "Service image changed while the configuration was being validated", + "CONFIGURATION_PLAN_STALE", + ); + } + const plan = planCanonicalConfiguration(currentState, input); + if (expectedVersion !== null && plan.currentVersion !== expectedVersion) { + domainError( + "Service configuration changed after the plan was created", + "CONFIGURATION_PLAN_STALE", + ); + } + if ( + input.placement.mode === "automatic" && (persisted.stateful || volumes.length > 0) ) { domainError( @@ -708,13 +895,13 @@ export async function patchConfiguration( domainError(blockers[0].message, blockers[0].code); } - if (input.source && input.source.type !== persisted.sourceType) { + if (input.source.type !== persisted.sourceType) { domainError( "Source type conversion is not supported; change the source in the web UI", "SOURCE_TYPE_CONVERSION", ); } - if (input.source?.type === "github") { + if (input.source.type === "github") { if (source.type !== "github" || !source.repository) { domainError( "The service does not have a valid linked GitHub repository", @@ -731,7 +918,7 @@ export async function patchConfiguration( ); } } - if (input.placement?.mode === "manual") { + if (input.placement.mode === "manual") { const ids = input.placement.placements.map((item) => item.serverId); const selected = await tx .select({ @@ -769,63 +956,59 @@ export async function patchConfiguration( ); } - if (input.hostname) { + const duplicateHostname = await tx + .select({ id: services.id }) + .from(services) + .where( + and(eq(services.hostname, input.hostname), ne(services.id, service.id)), + ) + .limit(1) + .then((rows) => rows[0]); + if (duplicateHostname) { + domainError("Hostname is already in use", "HOSTNAME_CONFLICT"); + } + if ( + persisted.serverlessEnabled && + !input.ports.some((port) => port.public && port.domain) + ) { + domainError( + "Serverless services require a public HTTP port with a domain", + "SERVERLESS_PORT_REQUIRED", + 400, + ); + } + const portIssue = findServicePortValidationIssue( + input.ports.map((port) => ({ + containerPort: port.containerPort, + isPublic: port.public, + domain: port.domain ?? null, + protocol: "http" as const, + })), + ); + if (portIssue) { + domainError(portIssue.message, portIssue.code, 400); + } + const domains = input.ports.flatMap((port) => + port.public && port.domain ? [port.domain] : [], + ); + for (const domain of domains) { const duplicate = await tx - .select({ id: services.id }) - .from(services) + .select({ id: servicePorts.id }) + .from(servicePorts) .where( and( - eq(services.hostname, input.hostname), - ne(services.id, service.id), + eq(servicePorts.domain, domain), + ne(servicePorts.serviceId, service.id), ), ) .limit(1) .then((rows) => rows[0]); if (duplicate) { - domainError("Hostname is already in use", "HOSTNAME_CONFLICT"); + domainError("Port domain is already in use", "DOMAIN_CONFLICT"); } } - if (input.ports) { - if ( - persisted.serverlessEnabled && - !input.ports.some((port) => port.public && port.domain) - ) { - domainError( - "Serverless services require a public HTTP port with a domain", - "SERVERLESS_PORT_REQUIRED", - 400, - ); - } - const portIssue = findServicePortValidationIssue( - input.ports.map((port) => ({ - containerPort: port.containerPort, - isPublic: port.public, - domain: port.domain ?? null, - protocol: "http" as const, - })), - ); - if (portIssue) { - domainError(portIssue.message, portIssue.code, 400); - } - const domains = input.ports.flatMap((port) => - port.public && port.domain ? [port.domain] : [], - ); - for (const domain of domains) { - const duplicate = await tx - .select({ id: servicePorts.id }) - .from(servicePorts) - .where( - and( - eq(servicePorts.domain, domain), - ne(servicePorts.serviceId, service.id), - ), - ) - .limit(1) - .then((rows) => rows[0]); - if (duplicate) { - domainError("Port domain is already in use", "DOMAIN_CONFLICT"); - } - } + if (expectedVersion === null) { + return { targetServiceName: persisted.name, ...plan }; } const changes: string[] = []; @@ -835,21 +1018,22 @@ export async function patchConfiguration( changes.push(label); return true; }; + if (changed("name", persisted.name, input.name)) set.name = input.name; - if ( - input.hostname !== undefined && - changed("hostname", persisted.hostname, input.hostname) - ) { + const hostnameChanged = changed( + "hostname", + currentState.hostname, + input.hostname, + ); + if (!persisted.hostname?.trim() || hostnameChanged) { set.hostname = input.hostname; } if ( - input.startCommand !== undefined && - changed("startCommand", persisted.startCommand, input.startCommand) + changed("startCommand", currentState.startCommand, input.startCommand) ) { set.startCommand = input.startCommand; } if ( - input.healthCheck !== undefined && changed( "healthCheck", healthCheckFromService(persisted), @@ -876,21 +1060,23 @@ export async function patchConfiguration( ); } if ( - input.resources !== undefined && changed( "resources", - { - cpuCores: persisted.resourceCpuLimit, - memoryMb: persisted.resourceMemoryLimitMb, - }, + persisted.resourceCpuLimit == null && + persisted.resourceMemoryLimitMb == null + ? null + : { + cpuCores: persisted.resourceCpuLimit, + memoryMb: persisted.resourceMemoryLimitMb, + }, input.resources, ) ) { - set.resourceCpuLimit = input.resources.cpuCores; - set.resourceMemoryLimitMb = input.resources.memoryMb; + set.resourceCpuLimit = input.resources?.cpuCores ?? null; + set.resourceMemoryLimitMb = input.resources?.memoryMb ?? null; } if ( - input.source?.type === "image" && + input.source.type === "image" && changed("source.image", persisted.image, input.source.image) ) { set.image = input.source.image; @@ -939,7 +1125,7 @@ export async function patchConfiguration( ); } } - if (input.source?.type === "github") { + if (input.source.type === "github") { const effectiveBranch = repo?.deployBranch || repo?.defaultBranch || @@ -954,11 +1140,9 @@ export async function patchConfiguration( .where(eq(githubRepos.id, repo.id)); } } - if (input.source.rootDir !== undefined) { - const desiredRoot = input.source.rootDir; - if (changed("source.rootDir", persisted.githubRootDir, desiredRoot)) { - set.githubRootDir = desiredRoot; - } + const desiredRoot = input.source.rootDir; + if (changed("source.rootDir", persisted.githubRootDir, desiredRoot)) { + set.githubRootDir = desiredRoot; } } @@ -1016,9 +1200,6 @@ export async function patchConfiguration( } } - return { - action: changes.length > 0 ? ("updated" as const) : ("noop" as const), - changes, - }; + return { targetServiceName: persisted.name, ...plan }; }); } diff --git a/web/lib/service-revision-spec.ts b/web/lib/service-revision-spec.ts index c14f461e..0bad7661 100644 --- a/web/lib/service-revision-spec.ts +++ b/web/lib/service-revision-spec.ts @@ -1,10 +1,21 @@ export const SERVICE_REVISION_SCHEMA_VERSION = 3 as const; -export function getDefaultServiceHostname(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); +export function getDefaultServiceHostname( + name: string, + serviceId: string, +): string { + const dnsLabel = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + const fromId = dnsLabel(serviceId); + const value = dnsLabel(name) || (fromId ? `service-${fromId}` : "service"); + if (value.length <= 63) return value; + + const suffix = (fromId || "service").slice(0, 8); + const prefix = value.slice(0, 62 - suffix.length).replace(/-+$/g, ""); + return `${prefix}-${suffix}`; } export type ServiceRevisionHealthCheck = { @@ -164,6 +175,7 @@ export type ServiceRevisionSpec = { export type ServiceRevisionDraft = { service: { + id: string; name: string; image: string; hostname: string | null; @@ -279,7 +291,8 @@ export function buildServiceRevisionSpec( image, source: overrides.source ?? { type: "image", image }, hostname: - service.hostname?.trim() || getDefaultServiceHostname(service.name), + service.hostname?.trim() || + getDefaultServiceHostname(service.name, service.id), stateful: service.stateful ?? false, serverless: { enabled: service.serverlessEnabled ?? false, diff --git a/web/tests/inngest-route.test.ts b/web/tests/inngest-route.test.ts new file mode 100644 index 00000000..e5fb14ef --- /dev/null +++ b/web/tests/inngest-route.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +type ServeOptions = { + functions: unknown[]; +}; + +const mocks = vi.hoisted(() => { + const functions = { + agentUpgradeTimeoutCheck: { id: "agent-upgrade-timeout-check" }, + backupWorkflow: { id: "backup-workflow" }, + buildTriggerWorkflow: { id: "build-trigger-workflow" }, + buildWorkflow: { id: "build-workflow" }, + certificateRenewal: { id: "certificate-renewal" }, + challengeCleanup: { id: "challenge-cleanup" }, + controlPlaneUpdateCheck: { id: "control-plane-update-check" }, + expiredDeletedServicesPurge: { id: "expired-deleted-services-purge" }, + migrationWorkflow: { id: "migration-workflow" }, + oldBackupsCleanup: { id: "old-backups-cleanup" }, + onDeploymentFailed: { id: "on-deployment-failed" }, + onRestoreFailed: { id: "on-restore-failed" }, + restoreTriggerWorkflow: { id: "restore-trigger-workflow" }, + restoreWorkflow: { id: "restore-workflow" }, + rolloutWorkflow: { id: "rollout-workflow" }, + scheduledBackupsCheck: { id: "scheduled-backups-check" }, + scheduledDeploymentsCheck: { id: "scheduled-deployments-check" }, + serviceDeletionWorkflow: { id: "service-deletion-workflow" }, + serviceRestoreWorkflow: { id: "service-restore-workflow" }, + staleItemsCleanup: { id: "stale-items-cleanup" }, + staleServerCheck: { id: "stale-server-check" }, + }; + + return { + functions, + serve: vi.fn((_options: ServeOptions) => ({ + GET: vi.fn(), + POST: vi.fn(), + PUT: vi.fn(), + })), + }; +}); + +vi.mock("inngest/next", () => ({ serve: mocks.serve })); +vi.mock("@/lib/inngest/client", () => ({ inngest: { id: "test" } })); +vi.mock("@/lib/inngest/functions", () => mocks.functions); + +import "@/app/api/inngest/route"; + +describe("Inngest route", () => { + it("registers every configured function", () => { + expect(mocks.serve).toHaveBeenCalledOnce(); + const options = mocks.serve.mock.calls[0]?.[0]; + + expect(options?.functions).toEqual( + expect.arrayContaining(Object.values(mocks.functions)), + ); + expect(options?.functions).toHaveLength( + Object.keys(mocks.functions).length, + ); + }); +}); diff --git a/web/tests/public-api-configuration.test.ts b/web/tests/public-api-configuration.test.ts index f17dce62..904c08f3 100644 --- a/web/tests/public-api-configuration.test.ts +++ b/web/tests/public-api-configuration.test.ts @@ -29,7 +29,7 @@ describe("public API configuration state", () => { mocks.select.mockClear(); }); - it("does not report a derived default hostname as a pending change", async () => { + it("derives a stable default hostname from immutable service identity", async () => { mocks.rows.push( [], [], @@ -84,7 +84,7 @@ describe("public API configuration state", () => { backupSchedule: null, } as never); - expect(configuration.current.hostname).toBeNull(); + expect(configuration.current.hostname).toBe("hello-service"); expect(configuration.active?.hostname).toBe("hello-service"); expect(configuration.hasPendingChanges).toBe(false); expect(configuration.changes).toEqual([]); diff --git a/web/tests/public-api-plan.test.ts b/web/tests/public-api-plan.test.ts new file mode 100644 index 00000000..afe86240 --- /dev/null +++ b/web/tests/public-api-plan.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { canonicalDesired, planCanonicalConfiguration } from "@/lib/public-api"; +import { parseConfigurationIfMatch } from "@/lib/public-api-routes"; + +const version = `sha256:${"a".repeat(64)}`; + +describe("configuration plan protocol", () => { + it.each([ + [null, null], + [version, null], + [`W/"${version}"`, null], + [`"${version}", "${version}"`, null], + [`"sha256:${"A".repeat(64)}"`, null], + [`"${version}"`, version], + ])("parses If-Match %j", (input, expected) => { + expect(parseConfigurationIfMatch(input)).toBe(expected); + }); + + it("normalizes an omitted private port domain to the apply representation", () => { + const desired = canonicalDesired({ + name: "web", + source: { type: "image", image: "nginx:1.27" }, + hostname: "web", + ports: [{ containerPort: 8080, public: false }], + placement: { mode: "automatic", replicas: 1 }, + healthCheck: null, + startCommand: null, + resources: null, + }); + expect(desired.ports).toEqual([ + { containerPort: 8080, public: false, domain: null }, + ]); + }); + + it("treats GitHub repository casing as the same repository identity", () => { + const current = { + name: "web", + source: { + type: "github" as const, + repository: "https://github.com/Techulus/Cloud", + branch: "main", + rootDir: null, + }, + hostname: "web", + ports: [], + placement: { mode: "automatic" as const, replicas: 1 }, + healthCheck: null, + startCommand: null, + resources: null, + }; + const result = planCanonicalConfiguration(current, { + ...current, + source: { + ...current.source, + repository: "https://github.com/techulus/cloud", + }, + }); + + expect(result.action).toBe("noop"); + expect(result.changes).toEqual([]); + }); + + it("sorts ports and manual placements deterministically", () => { + const desired = canonicalDesired({ + name: "web", + source: { type: "image", image: "nginx" }, + hostname: "web", + ports: [ + { containerPort: 9000, public: false }, + { containerPort: 8000, public: false }, + ], + placement: { + mode: "manual", + placements: [ + { serverId: "z", count: 1 }, + { serverId: "a", count: 1 }, + ], + }, + healthCheck: null, + startCommand: null, + resources: null, + }); + expect(desired.ports.map((port) => port.containerPort)).toEqual([ + 8000, 9000, + ]); + expect( + desired.placement.mode === "manual" && + desired.placement.placements.map((placement) => placement.serverId), + ).toEqual(["a", "z"]); + }); + + it("reports every managed field change, including removals and null clears", () => { + const current = { + name: "old-web", + source: { + type: "github" as const, + repository: "https://github.com/acme/web", + branch: "develop", + rootDir: "apps/web", + }, + hostname: "web", + ports: [{ containerPort: 8080, public: true, domain: "old.example.com" }], + placement: { + mode: "manual" as const, + placements: [{ serverId: "server-a", count: 1 }], + }, + healthCheck: { + cmd: "curl localhost:8080", + interval: 10, + timeout: 5, + retries: 3, + startPeriod: 30, + }, + startCommand: "npm start", + resources: { cpuCores: 2, memoryMb: 512 }, + }; + const result = planCanonicalConfiguration(current, { + name: "web", + source: { + type: "github", + repository: "https://github.com/acme/web", + branch: "main", + rootDir: null, + }, + hostname: "web-internal", + ports: [{ containerPort: 3000, public: false }], + placement: { mode: "automatic", replicas: 3 }, + healthCheck: null, + startCommand: null, + resources: null, + }); + + expect(result.action).toBe("updated"); + expect(result.currentVersion).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(result.desiredVersion).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(result.changes.map((change) => change.field)).toEqual([ + "name", + "source.branch", + "source.rootDir", + "hostname", + "ports", + "placement.mode", + "placement.placements", + "placement.replicas", + "healthCheck", + "startCommand", + "resources", + ]); + expect( + result.changes.find((change) => change.field === "source.rootDir"), + ).toEqual({ field: "source.rootDir", from: "apps/web", to: null }); + for (const change of JSON.parse(JSON.stringify(result.changes))) { + expect(Object.hasOwn(change, "from")).toBe(true); + expect(Object.hasOwn(change, "to")).toBe(true); + } + }); + + it("produces a no-op and stable desired fingerprint for equivalent ordering", () => { + const current = { + name: "web", + source: { type: "image" as const, image: "nginx" }, + hostname: "web", + ports: [ + { containerPort: 8000, public: false, domain: null }, + { containerPort: 9000, public: false, domain: null }, + ], + placement: { + mode: "manual" as const, + placements: [ + { serverId: "a", count: 1 }, + { serverId: "z", count: 1 }, + ], + }, + healthCheck: null, + startCommand: null, + resources: null, + }; + const desired = { + name: "web", + source: { type: "image" as const, image: "nginx" }, + hostname: "web", + ports: [ + { containerPort: 9000, public: false }, + { containerPort: 8000, public: false }, + ], + placement: { + mode: "manual" as const, + placements: [ + { serverId: "z", count: 1 }, + { serverId: "a", count: 1 }, + ], + }, + healthCheck: null, + startCommand: null, + resources: null, + }; + const first = planCanonicalConfiguration(current, desired); + const second = planCanonicalConfiguration(current, { + ...desired, + ports: desired.ports.toReversed(), + placement: { + ...desired.placement, + placements: desired.placement.placements.toReversed(), + }, + }); + + expect(first.action).toBe("noop"); + expect(first.changes).toEqual([]); + expect(second.desiredVersion).toBe(first.desiredVersion); + }); +}); diff --git a/web/tests/public-api-revisions-auth.test.ts b/web/tests/public-api-revisions-auth.test.ts index b6a61597..8c139938 100644 --- a/web/tests/public-api-revisions-auth.test.ts +++ b/web/tests/public-api-revisions-auth.test.ts @@ -32,7 +32,7 @@ const mocks = vi.hoisted(() => ({ { status: 401 }, ), })), - findNestedService: vi.fn(async () => ({ id: "service-1" })), + findService: vi.fn(async () => ({ id: "service-1" })), queryServiceRevisionChangelog: vi.fn(async () => ({ revisions: [ { @@ -60,25 +60,33 @@ vi.mock("@/lib/api-auth", () => ({ vi.mock("@/lib/public-api", async (importOriginal) => ({ ...(await importOriginal()), - findNestedService: mocks.findNestedService, + findServiceContext: async (_serviceId: string) => { + const service = await mocks.findService(); + return service + ? { + service, + projectId: "project-1", + projectSlug: "project", + environmentId: "environment-1", + environmentName: "production", + } + : null; + }, })); vi.mock("@/lib/service-revision-changelog", () => ({ queryServiceRevisionChangelog: mocks.queryServiceRevisionChangelog, })); -import { GET } from "@/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/revisions/route"; +import { GET } from "@/app/api/v1/services/[serviceId]/revisions/route"; it("lists public revisions with an API key without requiring a browser session", async () => { const response = await GET( - new Request( - "https://cloud.test/api/v1/projects/project-1/environments/environment-1/services/service-1/revisions", - { headers: { "x-api-key": "tcl_secret" } }, - ), + new Request("https://cloud.test/api/v1/services/service-1/revisions", { + headers: { "x-api-key": "tcl_secret" }, + }), { params: Promise.resolve({ - projectId: "project-1", - environmentId: "environment-1", serviceId: "service-1", }), }, diff --git a/web/tests/public-api-source.test.ts b/web/tests/public-api-source.test.ts index 030625a6..7cfcb4c7 100644 --- a/web/tests/public-api-source.test.ts +++ b/web/tests/public-api-source.test.ts @@ -1,11 +1,23 @@ import { describe, expect, it } from "vitest"; import { canonicalGitHubRepository, - configurationPatchSchema, isSafeRepositoryRoot, publicSourceSchema, + replaceConfigurationSchema, } from "@/lib/public-api"; +const completeConfiguration = (overrides: Record = {}) => ({ + name: "web", + source: { type: "image", image: "nginx:1.27" }, + hostname: "web", + ports: [], + placement: { mode: "automatic", replicas: 1 }, + healthCheck: null, + startCommand: null, + resources: null, + ...overrides, +}); + describe("public API GitHub sources", () => { it.each([ [ @@ -72,13 +84,16 @@ describe("public API GitHub sources", () => { it("requires a nonblank GitHub branch", () => { expect( - configurationPatchSchema.safeParse({ - source: { - type: "github", - repository: "https://github.com/owner/repository", - branch: " ", - }, - }).success, + replaceConfigurationSchema.safeParse( + completeConfiguration({ + source: { + type: "github", + repository: "https://github.com/owner/repository", + branch: " ", + rootDir: null, + }, + }), + ).success, ).toBe(false); }); @@ -92,31 +107,48 @@ describe("public API GitHub sources", () => { type: "github", repository: "https://github.com/owner/repository", branch: "main", + rootDir: null, image: "registry.example/app:latest", }, ])("rejects mixed source fields", (source) => { - expect(configurationPatchSchema.safeParse({ source }).success).toBe(false); + expect( + replaceConfigurationSchema.safeParse(completeConfiguration({ source })) + .success, + ).toBe(false); }); - it("distinguishes omitted rootDir from explicit null", () => { - const omitted = publicSourceSchema.parse({ + it("requires explicit null to clear rootDir", () => { + const omitted = publicSourceSchema.safeParse({ type: "github", repository: "https://github.com/owner/repository", branch: "main", }); const cleared = publicSourceSchema.parse({ - ...omitted, + type: "github", + repository: "https://github.com/owner/repository", + branch: "main", rootDir: null, }); - expect(omitted).not.toHaveProperty("rootDir"); + expect(omitted.success).toBe(false); expect(cleared).toHaveProperty("rootDir", null); }); }); describe("public API placement schema", () => { + it("requires every managed field and rejects unknown fields", () => { + expect(replaceConfigurationSchema.safeParse({ name: "web" }).success).toBe( + false, + ); + expect( + replaceConfigurationSchema.safeParse( + completeConfiguration({ unmanaged: true }), + ).success, + ).toBe(false); + }); + it("rejects the removed standalone replicas field", () => { - expect(configurationPatchSchema.safeParse({ replicas: 3 }).success).toBe( + expect(replaceConfigurationSchema.safeParse({ replicas: 3 }).success).toBe( false, ); }); @@ -125,9 +157,10 @@ describe("public API placement schema", () => { { mode: "automatic", replicas: 3 }, { mode: "manual", placements: [{ serverId: "server-1", count: 2 }] }, ])("accepts valid placement intent", (placement) => { - expect(configurationPatchSchema.safeParse({ placement }).success).toBe( - true, - ); + expect( + replaceConfigurationSchema.safeParse(completeConfiguration({ placement })) + .success, + ).toBe(true); }); it.each([ @@ -148,8 +181,9 @@ describe("public API placement schema", () => { ], }, ])("rejects invalid placement intent", (placement) => { - expect(configurationPatchSchema.safeParse({ placement }).success).toBe( - false, - ); + expect( + replaceConfigurationSchema.safeParse(completeConfiguration({ placement })) + .success, + ).toBe(false); }); }); diff --git a/web/tests/service-revision-spec.test.ts b/web/tests/service-revision-spec.test.ts index a64d9a63..6bb02475 100644 --- a/web/tests/service-revision-spec.test.ts +++ b/web/tests/service-revision-spec.test.ts @@ -9,6 +9,7 @@ function draft( ): ServiceRevisionDraft { return { service: { + id: "service-1", name: "API Service", image: "nginx:latest", hostname: "api.internal", @@ -68,6 +69,33 @@ function draft( } describe("service revision specification", () => { + it("derives the legacy null-hostname fallback from the current service name", () => { + const original = draft(); + original.service.hostname = null; + const renamed = draft(); + renamed.service.hostname = null; + renamed.service.name = "Renamed API Service"; + + expect(buildServiceRevisionSpec(original).hostname).toBe("api-service"); + expect(buildServiceRevisionSpec(renamed).hostname).toBe( + "renamed-api-service", + ); + }); + + it("produces a valid DNS label for long and non-ASCII legacy names", () => { + const long = draft(); + long.service.hostname = null; + long.service.name = "a".repeat(100); + const nonAscii = draft(); + nonAscii.service.hostname = null; + nonAscii.service.name = "こんにちは"; + + expect(buildServiceRevisionSpec(long).hostname).toHaveLength(63); + expect(buildServiceRevisionSpec(nonAscii).hostname).toBe( + "service-service-1", + ); + }); + it("normalizes draft row ordering", () => { const first = buildServiceRevisionSpec(draft()); const reorderedDraft = draft();