From 4ee1d6ae5b21cc6e551352f2e729df9aab543096 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Tue, 21 Jul 2026 13:11:40 +1000 Subject: [PATCH] Add public API and migrate CLI Introduce nested X-API-Key resource endpoints, move the CLI to techulus.yml and the public API, and remove the pre-alpha manifest/CLI routes. Make GitHub builds revision-first so immutable source provenance and artifacts flow from build through rollout, while adding deterministic log cursors and deployment regression coverage. --- agent/internal/build/build.go | 49 +- agent/internal/build/build_test.go | 57 + agent/internal/logs/collector.go | 28 +- agent/internal/logs/event_id_test.go | 70 ++ agent/internal/logs/victoria.go | 2 + cli/.mise.toml | 1 + cli/internal/cli/app.go | 727 +++++++++--- cli/internal/cli/app_test.go | 865 ++++++-------- cli/internal/cli/types.go | 217 +--- cli/internal/manifest/manifest.go | 299 +++-- cli/internal/manifest/manifest_test.go | 160 ++- cli/justfile | 46 + docs/api/public-api.mdx | 292 +++++ docs/docs.json | 6 + web/actions/builds.ts | 151 ++- web/actions/projects.ts | 177 +-- .../[serviceId]/builds/[buildId]/page.tsx | 32 +- web/app/api/projects/[id]/services/route.ts | 22 +- web/app/api/services/[id]/revisions/route.ts | 194 +-- web/app/api/v1/agent/builds/[id]/route.ts | 222 ++-- .../api/v1/agent/builds/[id]/status/route.ts | 559 +++++---- web/app/api/v1/api-keys/route.ts | 41 + web/app/api/v1/cli/auth/exchange/route.ts | 61 - web/app/api/v1/cli/auth/whoami/route.ts | 22 - web/app/api/v1/manifest/apply/route.ts | 35 - web/app/api/v1/manifest/deploy/route.ts | 39 - web/app/api/v1/manifest/link-targets/route.ts | 26 - web/app/api/v1/manifest/link/route.ts | 43 - web/app/api/v1/manifest/logs/route.ts | 85 -- web/app/api/v1/manifest/status/route.ts | 49 - web/app/api/v1/me/route.ts | 23 + .../services/[serviceId]/builds/route.ts | 2 + .../[serviceId]/configuration/route.ts | 5 + .../services/[serviceId]/deploy/route.ts | 2 + .../services/[serviceId]/logs/route.ts | 2 + .../services/[serviceId]/metrics/route.ts | 2 + .../services/[serviceId]/revisions/route.ts | 2 + .../rollouts/[rolloutId]/logs/route.ts | 2 + .../[serviceId]/rollouts/[rolloutId]/route.ts | 2 + .../services/[serviceId]/rollouts/route.ts | 2 + .../services/[serviceId]/route.ts | 49 + .../services/[serviceId]/status/route.ts | 2 + .../[environmentId]/services/route.ts | 87 ++ .../[projectId]/environments/route.ts | 66 ++ web/app/api/v1/projects/route.ts | 53 + web/app/api/webhooks/github/route.ts | 38 +- web/components/builds/build-details.tsx | 4 +- .../service/service-layout-client.tsx | 1 + web/db/schema.ts | 21 +- web/db/types.ts | 3 +- web/lib/api-auth.ts | 161 ++- web/lib/auth.ts | 4 +- web/lib/build-assignment.ts | 150 +-- web/lib/build-revision-source.ts | 21 + web/lib/cli-service.ts | 1037 ----------------- web/lib/deploy-service.ts | 46 +- web/lib/docker-image.ts | 247 ++++ web/lib/github.ts | 48 + web/lib/inngest/events/build.ts | 13 +- web/lib/inngest/events/migration.ts | 1 + .../functions/build-trigger-workflow.ts | 177 ++- web/lib/inngest/functions/build-workflow.ts | 367 +++--- .../inngest/functions/migration-workflow.ts | 120 +- .../functions/service-deletion-workflow.ts | 70 +- web/lib/migrations.ts | 34 +- web/lib/public-api-pagination.ts | 162 +++ web/lib/public-api-routes.ts | 733 ++++++++++++ web/lib/public-api.ts | 871 ++++++++++++++ web/lib/scheduler.ts | 13 +- web/lib/service-config.ts | 93 +- web/lib/service-revision-changelog.ts | 158 +++ web/lib/service-revision-changes.ts | 37 +- web/lib/service-revision-spec.ts | 54 +- web/lib/service-revisions.ts | 441 ++++++- web/lib/trigger-build.ts | 211 ++++ web/lib/victoria-logs.ts | 169 ++- web/lib/victoria-metrics.ts | 179 +-- web/lib/work-queue.ts | 62 +- web/tests/api-key-session-isolation.test.ts | 14 + web/tests/build-actions.test.ts | 43 +- web/tests/build-assignment.test.ts | 278 ++--- web/tests/build-claim-route.test.ts | 109 ++ web/tests/build-revision-source.test.ts | 61 + web/tests/build-status-route.test.ts | 222 ++++ web/tests/build-trigger-workflow.test.ts | 171 +++ web/tests/build-workflow.test.ts | 290 +++++ web/tests/deploy-service-revision.test.ts | 234 ++++ web/tests/docker-image.test.ts | 158 +++ web/tests/expected-state.test.ts | 3 +- web/tests/github-webhook.test.ts | 54 +- web/tests/github.test.ts | 48 +- web/tests/log-routes.test.ts | 28 - web/tests/public-api-auth.test.ts | 239 ++++ web/tests/public-api-configuration.test.ts | 92 ++ web/tests/public-api-long-poll.test.ts | 98 ++ web/tests/public-api-pagination.test.ts | 138 +++ web/tests/public-api-revisions-auth.test.ts | 107 ++ web/tests/public-api-source.test.ts | 115 ++ web/tests/service-config.test.ts | 142 ++- web/tests/service-revision-build.test.ts | 219 ++++ web/tests/service-revision-changes.test.ts | 3 +- web/tests/service-revision-spec.test.ts | 71 ++ web/tests/service-revisions-route.test.ts | 3 +- web/tests/trigger-build.test.ts | 252 ++++ web/tests/victoria-logs.test.ts | 142 +++ 105 files changed, 9837 insertions(+), 4121 deletions(-) create mode 100644 agent/internal/logs/event_id_test.go create mode 100644 cli/justfile create mode 100644 docs/api/public-api.mdx create mode 100644 web/app/api/v1/api-keys/route.ts delete mode 100644 web/app/api/v1/cli/auth/exchange/route.ts delete mode 100644 web/app/api/v1/cli/auth/whoami/route.ts delete mode 100644 web/app/api/v1/manifest/apply/route.ts delete mode 100644 web/app/api/v1/manifest/deploy/route.ts delete mode 100644 web/app/api/v1/manifest/link-targets/route.ts delete mode 100644 web/app/api/v1/manifest/link/route.ts delete mode 100644 web/app/api/v1/manifest/logs/route.ts delete mode 100644 web/app/api/v1/manifest/status/route.ts create mode 100644 web/app/api/v1/me/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/builds/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/configuration/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/deploy/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/logs/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/metrics/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/revisions/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/logs/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/status/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts create mode 100644 web/app/api/v1/projects/[projectId]/environments/route.ts create mode 100644 web/app/api/v1/projects/route.ts create mode 100644 web/lib/build-revision-source.ts delete mode 100644 web/lib/cli-service.ts create mode 100644 web/lib/public-api-pagination.ts create mode 100644 web/lib/public-api-routes.ts create mode 100644 web/lib/public-api.ts create mode 100644 web/lib/service-revision-changelog.ts create mode 100644 web/lib/trigger-build.ts create mode 100644 web/tests/api-key-session-isolation.test.ts create mode 100644 web/tests/build-claim-route.test.ts create mode 100644 web/tests/build-revision-source.test.ts create mode 100644 web/tests/build-status-route.test.ts create mode 100644 web/tests/build-trigger-workflow.test.ts create mode 100644 web/tests/build-workflow.test.ts create mode 100644 web/tests/deploy-service-revision.test.ts create mode 100644 web/tests/docker-image.test.ts create mode 100644 web/tests/public-api-auth.test.ts create mode 100644 web/tests/public-api-configuration.test.ts create mode 100644 web/tests/public-api-long-poll.test.ts create mode 100644 web/tests/public-api-pagination.test.ts create mode 100644 web/tests/public-api-revisions-auth.test.ts create mode 100644 web/tests/public-api-source.test.ts create mode 100644 web/tests/service-revision-build.test.ts create mode 100644 web/tests/trigger-build.test.ts diff --git a/agent/internal/build/build.go b/agent/internal/build/build.go index 3fc7cf17..e54ed907 100644 --- a/agent/internal/build/build.go +++ b/agent/internal/build/build.go @@ -58,6 +58,7 @@ type dockerfileConfig struct { } var managedTempArtifactPattern = regexp.MustCompile(`^(backup|restore)-[0-9a-fA-F-]{36}\.tar\.gz$|^restore-extract-[0-9a-fA-F-]{36}$`) +var windowsAbsoluteRootPattern = regexp.MustCompile(`^[A-Za-z]:[\\/]`) func NewBuilder(dataDir string, logSender LogSender) *Builder { return &Builder{ @@ -224,12 +225,11 @@ func (b *Builder) resolveCommitSha(ctx context.Context, config *Config, buildDir } func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir string) error { - contextDir := buildDir + contextDir, err := resolveBuildContext(buildDir, config.RootDir) + if err != nil { + return err + } if config.RootDir != "" { - contextDir = filepath.Join(buildDir, config.RootDir) - if _, err := os.Stat(contextDir); err != nil { - return fmt.Errorf("root directory %s does not exist: %w", config.RootDir, err) - } b.sendLog(config, fmt.Sprintf("Using root directory: %s", config.RootDir)) } @@ -346,6 +346,45 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str return nil } +func resolveBuildContext(buildDir, rootDir string) (string, error) { + if rootDir == "" { + return buildDir, nil + } + + if windowsAbsoluteRootPattern.MatchString(rootDir) { + return "", fmt.Errorf("root directory %s must be relative to and inside the cloned repository", rootDir) + } + normalizedRoot := strings.ReplaceAll(rootDir, "\\", "/") + cleanedRoot := filepath.Clean(filepath.FromSlash(normalizedRoot)) + if filepath.IsAbs(cleanedRoot) || cleanedRoot == ".." || strings.HasPrefix(cleanedRoot, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("root directory %s must be relative to and inside the cloned repository", rootDir) + } + + contextDir := filepath.Join(buildDir, cleanedRoot) + info, err := os.Stat(contextDir) + if err != nil { + return "", fmt.Errorf("root directory %s does not exist: %w", rootDir, err) + } + if !info.IsDir() { + return "", fmt.Errorf("root directory %s is not a directory", rootDir) + } + + resolvedBuildDir, err := filepath.EvalSymlinks(buildDir) + if err != nil { + return "", fmt.Errorf("failed to resolve cloned repository directory: %w", err) + } + resolvedContextDir, err := filepath.EvalSymlinks(contextDir) + if err != nil { + return "", fmt.Errorf("failed to resolve root directory %s: %w", rootDir, err) + } + relativePath, err := filepath.Rel(resolvedBuildDir, resolvedContextDir) + if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("root directory %s must resolve inside the cloned repository", rootDir) + } + + return contextDir, nil +} + func resolveDockerfile(contextDir string, secrets map[string]string) (dockerfileConfig, error) { configuredPath, configured := secrets[dockerfilePathKey] configuredPath = strings.TrimSpace(configuredPath) diff --git a/agent/internal/build/build_test.go b/agent/internal/build/build_test.go index 235ad092..380676d1 100644 --- a/agent/internal/build/build_test.go +++ b/agent/internal/build/build_test.go @@ -119,6 +119,63 @@ func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) { } } +func TestResolveBuildContext(t *testing.T) { + buildDir := t.TempDir() + nestedDir := filepath.Join(buildDir, "services", "api") + if err := os.MkdirAll(nestedDir, 0700); err != nil { + t.Fatal(err) + } + outsideDir := t.TempDir() + + tests := []struct { + name string + rootDir string + want string + wantErr bool + }{ + {name: "repository root", rootDir: "", want: buildDir}, + {name: "safe nested root", rootDir: filepath.Join("services", "api"), want: nestedDir}, + {name: "windows separators", rootDir: `services\api`, want: nestedDir}, + {name: "absolute path", rootDir: outsideDir, wantErr: true}, + {name: "windows absolute path", rootDir: `C:\repo`, wantErr: true}, + {name: "UNC path", rootDir: `\\server\share`, wantErr: true}, + {name: "parent escape", rootDir: "..", wantErr: true}, + {name: "nested parent escape", rootDir: filepath.Join("services", "..", ".."), wantErr: true}, + {name: "windows parent escape", rootDir: `services\..\..`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveBuildContext(buildDir, tt.rootDir) + if tt.wantErr { + if err == nil || !strings.Contains(err.Error(), "root directory") { + t.Fatalf("resolveBuildContext() error = %v, want clear root directory error", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("resolveBuildContext() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveBuildContextRejectsSymlinkEscape(t *testing.T) { + buildDir := t.TempDir() + outsideDir := t.TempDir() + if err := os.Symlink(outsideDir, filepath.Join(buildDir, "outside")); err != nil { + t.Skipf("symlinks are not supported: %v", err) + } + + _, err := resolveBuildContext(buildDir, "outside") + if err == nil || !strings.Contains(err.Error(), "must resolve inside the cloned repository") { + t.Fatalf("resolveBuildContext() error = %v, want symlink containment error", err) + } +} + func TestResolveDockerfile(t *testing.T) { contextDir := t.TempDir() if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte("FROM scratch"), 0600); err != nil { diff --git a/agent/internal/logs/collector.go b/agent/internal/logs/collector.go index 16f1ce45..800f8566 100644 --- a/agent/internal/logs/collector.go +++ b/agent/internal/logs/collector.go @@ -2,6 +2,8 @@ package logs import ( "context" + "crypto/rand" + "fmt" "log" "sync" "time" @@ -10,13 +12,14 @@ import ( ) const ( - maxBatchSize = 1000 - flushInterval = 5 * time.Second - maxQueueSize = 10000 - defaultSince = "1m" + maxBatchSize = 1000 + flushInterval = 5 * time.Second + maxQueueSize = 10000 + defaultSince = "1m" ) type LogEntry struct { + EventID string DeploymentID string ServiceID string Stream string @@ -24,6 +27,17 @@ type LogEntry struct { Timestamp string } +func newLogEventID(now time.Time) (string, error) { + random := make([]byte, 26) + if _, err := rand.Read(random); err != nil { + return "", fmt.Errorf("generate random log event ID: %w", err) + } + for i := range random { + random[i] = 'a' + random[i]%26 + } + return fmt.Sprintf("e%019d%s", now.UnixNano(), random), nil +} + type LogBatch struct { Logs []LogEntry } @@ -143,7 +157,13 @@ func (c *Collector) collectFromContainer(ctr ContainerInfo) { var lastTimestamp time.Time for entry := range entryCh { + eventID, err := newLogEventID(time.Now()) + if err != nil { + log.Printf("[logs] failed to identify log from container %s: %v", ctr.ContainerID, err) + continue + } logEntry := LogEntry{ + EventID: eventID, DeploymentID: ctr.DeploymentID, ServiceID: ctr.ServiceID, Stream: entry.Stream, diff --git a/agent/internal/logs/event_id_test.go b/agent/internal/logs/event_id_test.go new file mode 100644 index 00000000..31314f6b --- /dev/null +++ b/agent/internal/logs/event_id_test.go @@ -0,0 +1,70 @@ +package logs + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" +) + +func TestNewLogEventIDIsSortableAndUnique(t *testing.T) { + now := time.Date(2026, time.July, 20, 12, 0, 0, 123456789, time.UTC) + first, err := newLogEventID(now) + if err != nil { + t.Fatal(err) + } + second, err := newLogEventID(now) + if err != nil { + t.Fatal(err) + } + + pattern := regexp.MustCompile(`^e[0-9]{19}[a-z]{26}$`) + if !pattern.MatchString(first) { + t.Fatalf("event ID %q does not match the public cursor format", first) + } + if !pattern.MatchString(second) { + t.Fatalf("event ID %q does not match the public cursor format", second) + } + if first == second { + t.Fatalf("equal collection times generated duplicate event IDs: %q", first) + } +} + +func TestVictoriaLogsSenderPreservesEventID(t *testing.T) { + const eventID = "e1784546100123456789abcdefghijklmnopqrstuvwxyz" + var body []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + body, err = io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + sender := NewVictoriaLogsSender(server.URL, "server-1") + err := sender.SendLogs(&LogBatch{Logs: []LogEntry{{ + EventID: eventID, + DeploymentID: "deployment-1", + ServiceID: "service-1", + Stream: "stdout", + Message: "ready", + Timestamp: "2026-07-20T12:00:00Z", + }}}) + if err != nil { + t.Fatal(err) + } + + var entry map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(string(body))), &entry); err != nil { + t.Fatal(err) + } + if got := entry["event_id"]; got != eventID { + t.Fatalf("event_id = %v, want %s", got, eventID) + } +} diff --git a/agent/internal/logs/victoria.go b/agent/internal/logs/victoria.go index 59064a04..df845b67 100644 --- a/agent/internal/logs/victoria.go +++ b/agent/internal/logs/victoria.go @@ -50,6 +50,7 @@ func (v *VictoriaLogsSender) setAuthHeader(req *http.Request) { type victoriaLogEntry struct { Msg string `json:"_msg"` Time string `json:"_time"` + EventID string `json:"event_id"` DeploymentID string `json:"deployment_id"` ServiceID string `json:"service_id"` ServerID string `json:"server_id"` @@ -66,6 +67,7 @@ func (v *VictoriaLogsSender) SendLogs(batch *LogBatch) error { entry := victoriaLogEntry{ Msg: l.Message, Time: l.Timestamp, + EventID: l.EventID, DeploymentID: l.DeploymentID, ServiceID: l.ServiceID, ServerID: v.serverID, diff --git a/cli/.mise.toml b/cli/.mise.toml index b95a26e9..5d1d8840 100644 --- a/cli/.mise.toml +++ b/cli/.mise.toml @@ -1,2 +1,3 @@ [tools] go = "1.25.5" +just = "1.56.0" diff --git a/cli/internal/cli/app.go b/cli/internal/cli/app.go index 29a04d96..dafba493 100644 --- a/cli/internal/cli/app.go +++ b/cli/internal/cli/app.go @@ -3,12 +3,14 @@ package cli import ( "bufio" "context" + "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" + "os/signal" "path/filepath" "runtime" "strconv" @@ -79,7 +81,6 @@ func NewApp(version string, in io.Reader, out io.Writer, errOut io.Writer) *App Out: out, Err: errOut, HTTPClient: &http.Client{Timeout: defaultAPITimeout}, - Sleep: time.Sleep, Now: time.Now, IsInteractive: func() bool { inFile, inOK := in.(*os.File) @@ -110,7 +111,9 @@ func (a *App) Execute() error { if a.Args != nil { cmd.SetArgs(a.Args) } - if err := cmd.Execute(); err != nil { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err := cmd.ExecuteContext(ctx); err != nil { if a.isMachineOutput() { _ = a.writeError(err) return handledError{err: err} @@ -153,6 +156,14 @@ func (a *App) rootCommand() *cobra.Command { root.AddCommand(a.deployCommand()) root.AddCommand(a.statusCommand()) root.AddCommand(a.logsCommand()) + root.AddCommand(a.environmentsCommand()) + root.AddCommand(a.servicesCommand()) + root.AddCommand(a.resourceCommand("config", "Show full service configuration", "/configuration", nil)) + root.AddCommand(a.paginatedCommand("rollouts", "List rollout history", "/rollouts")) + root.AddCommand(a.rolloutCommand()) + root.AddCommand(a.paginatedCommand("builds", "List build history", "/builds")) + root.AddCommand(a.metricsCommand()) + root.AddCommand(a.revisionsCommand()) root.AddCommand(a.versionCommand()) root.AddCommand(a.completionCommand(root)) @@ -235,7 +246,7 @@ func (a *App) authWhoamiCommand() *cobra.Command { User auth.User `json:"user"` } client := a.client(config) - if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/cli/auth/whoami", nil, nil, &response); err != nil { + if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/me", nil, nil, &response); err != nil { return err } result := authWhoamiOutput{ @@ -278,28 +289,32 @@ func (a *App) initCommand() *cobra.Command { folderName = "my-service" } starter := fmt.Sprintf(`apiVersion: v1 -project: %s -environment: production +project: + slug: %s +environment: + name: production service: name: %s source: type: image image: nginx:1.27 - replicas: - count: 1 + hostname: null + replicas: 1 + healthCheck: null + startCommand: null ports: - - port: 80 + - containerPort: 80 public: false `, folderName, folderName) if err := os.WriteFile(manifestPath, []byte(starter), 0o644); err != nil { return err } if a.isMachineOutput() { - return a.writeData(initOutput{Manifest: manifestPath, Next: "tc apply"}, "Manifest created") + return a.writeData(initOutput{Manifest: manifestPath, Next: "tc link"}, "Manifest created") } output.Section(a.Out, "Manifest") output.Field(a.Out, "Created", manifestPath) - output.Next(a.Out, "tc apply") + output.Next(a.Out, "tc link") return nil }, } @@ -307,6 +322,7 @@ service: func (a *App) linkCommand() *cobra.Command { var force bool + var projectID, environmentID, serviceID string cmd := &cobra.Command{ Use: "link", Short: "Create techulus.yml from an existing service", @@ -315,10 +331,19 @@ func (a *App) linkCommand() *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { if a.isMachineOutput() { - return errors.New("tc link requires an interactive terminal and does not support --agent or --json") + return errors.New("tc link does not support --agent or --json") } - if !a.IsInteractive() { - return errors.New("tc link requires an interactive terminal") + 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") } config, err := requireConfig() if err != nil { @@ -336,47 +361,128 @@ func (a *App) linkCommand() *cobra.Command { } client := a.client(config) - var targets linkTargetsResponse - if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/manifest/link-targets", nil, nil, &targets); err != nil { + ps, err := fetchAllProjects(cmd.Context(), client) + if err != nil { return err } - if countSupportedServices(targets.Projects) == 0 { - return errors.New("no linkable services were found in your account") - } - projectChoices := filterProjectsWithServices(targets.Projects) - if len(projectChoices) == 0 { - return errors.New("no services were found in your account") + if len(ps.Projects) == 0 { + return errors.New("no projects found") } reader := bufio.NewReader(a.In) - project, err := selectFromList(reader, a.Out, "Select a project:", projectChoices, renderProjectChoice, nil) - if err != nil { - return err + 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 }, nil) + if err != nil { + return err + } } - environmentChoices := filterEnvironmentsWithServices(project.Environments) - environment, err := selectFromList(reader, a.Out, "Select an environment:", environmentChoices, renderEnvironmentChoice, nil) + ep := "/api/v1/projects/" + url.PathEscape(project.ID) + "/environments" + es, err := fetchAllEnvironments(cmd.Context(), client, ep) if err != nil { return err } - service, err := selectFromList(reader, a.Out, "Select a service:", environment.Services, renderServiceChoice, disabledServiceReason) + 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") + } + } else { + environment, err = selectFromList(reader, a.Out, "Select an environment:", es.Environments, func(v environmentItem) string { return v.Name }, nil) + if err != nil { + return err + } + } + sp := ep + "/" + url.PathEscape(environment.ID) + "/services" + ss, err := fetchAllServices(cmd.Context(), client, sp) if err != nil { return err } - - var result linkManifestResponse - if err := client.RequestJSON(cmd.Context(), http.MethodPost, "/api/v1/manifest/link", nil, map[string]string{"serviceId": service.ID}, &result); err != nil { + 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 }, nil) + if err != nil { + return err + } + } + var cfg struct { + Current struct { + Hostname *string `json:"hostname"` + Ports []struct { + ContainerPort int `json:"containerPort"` + IsPublic bool `json:"public"` + Domain *string `json:"domain"` + } `json:"ports"` + Replicas int `json:"replicas"` + HealthCheck *manifest.HealthCheck `json:"healthCheck"` + 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) + if err := client.RequestJSON(cmd.Context(), http.MethodGet, base+"/configuration", nil, nil, &cfg); err != nil { return err } - if err := manifest.Save(manifestPath, result.Manifest); err != nil { + 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 cfg.Current.Resources != nil && cfg.Current.Resources.CPUCores == nil && cfg.Current.Resources.MemoryMB == nil { + cfg.Current.Resources = nil + } + ports := make([]manifest.Port, len(cfg.Current.Ports)) + for i, p := range cfg.Current.Ports { + ports[i] = manifest.Port{ContainerPort: p.ContainerPort, Public: p.IsPublic, Domain: p.Domain} + } + 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, HealthCheck: cfg.Current.HealthCheck, StartCommand: cfg.Current.StartCommand, Resources: cfg.Current.Resources}} + if err := manifest.Save(manifestPath, m); err != nil { return err } output.Section(a.Out, "Linked") - output.Field(a.Out, "Service", fmt.Sprintf("%s/%s/%s", result.Service.Project, result.Service.Environment, result.Service.Name)) + output.Field(a.Out, "Service", fmt.Sprintf("%s/%s/%s", project.Slug, environment.Name, service.Name)) output.Field(a.Out, "Manifest", manifestPath) output.Next(a.Out, "tc status or tc apply") 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 } @@ -398,7 +504,14 @@ func (a *App) applyCommand() *cobra.Command { } var result applyResponse client := a.client(config) - if err := client.RequestJSON(cmd.Context(), http.MethodPost, "/api/v1/manifest/apply", nil, loaded.Manifest, &result); err != nil { + if !loaded.Manifest.Linked() { + return errors.New("service is not linked: run `tc link`") + } + body := map[string]any{"source": sourcePatch(loaded.Manifest.Service.Source), "hostname": loaded.Manifest.Service.Hostname, "ports": loaded.Manifest.Service.Ports, "replicas": loaded.Manifest.Service.Replicas, "healthCheck": loaded.Manifest.Service.HealthCheck, "startCommand": loaded.Manifest.Service.StartCommand} + if loaded.Manifest.Service.Resources != nil { + body["resources"] = loaded.Manifest.Service.Resources + } + if err := client.RequestJSON(cmd.Context(), http.MethodPatch, serviceBase(loaded.Manifest)+"/configuration", nil, body, &result); err != nil { return err } if a.isMachineOutput() { @@ -428,18 +541,35 @@ func (a *App) deployCommand() *cobra.Command { } var result deployResponse client := a.client(config) - if err := client.RequestJSON(cmd.Context(), http.MethodPost, "/api/v1/manifest/deploy", nil, loaded.Manifest, &result); err != nil { + if !loaded.Manifest.Linked() { + return errors.New("service is not linked: run `tc link`") + } + 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 { + 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 { return err } if a.isMachineOutput() { return a.writeData(result, "Deploy") } output.Section(a.Out, "Deploy") - output.Field(a.Out, "Service", output.ShortID(result.ServiceID)) + output.Field(a.Out, "Operation", result.Operation) output.Field(a.Out, "Status", output.Status(result.Status)) if result.RolloutID != nil && *result.RolloutID != "" { output.Field(a.Out, "Rollout", output.ShortID(*result.RolloutID)) } + if result.Operation == "build" { + output.Field(a.Out, "Next", "build queued; a rollout starts after it succeeds") + } output.Next(a.Out, "tc status") return nil }, @@ -465,16 +595,11 @@ func (a *App) statusCommand() *cobra.Command { } var status statusResponse client := a.client(config) - query := manifestIdentityQuery(value) - if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/manifest/status", query, nil, &status); err != nil { + if err := client.RequestJSON(cmd.Context(), http.MethodGet, serviceBase(value)+"/status", nil, nil, &status); err != nil { return err } - result := statusOutput{ - Target: serviceTargetFromManifest(value), - Status: status, - } if a.isMachineOutput() { - return a.writeData(result, "Status") + return a.writeData(status, "Status") } printStatus(a.Out, value, status) return nil @@ -487,6 +612,7 @@ func (a *App) statusCommand() *cobra.Command { func (a *App) logsCommand() *cobra.Command { var tail int var follow bool + var query, logRange string var target serviceTargetFlags cmd := &cobra.Command{ Use: "logs", @@ -498,10 +624,6 @@ func (a *App) logsCommand() *cobra.Command { if tail < 1 || tail > 1000 { return errors.New("log line count must be between 1 and 1000") } - tailChanged := cmd.Flags().Changed("tail") - if tailChanged && !cmd.Flags().Changed("follow") { - follow = false - } if a.isMachineOutput() { if cmd.Flags().Changed("follow") && follow { return errors.New("--follow=true is not supported with --agent or --json") @@ -516,15 +638,226 @@ func (a *App) logsCommand() *cobra.Command { if err != nil { return err } - return a.runLogs(cmd.Context(), config, value, tail, follow) + if logRange != "" && !contains([]string{"1h", "6h", "24h", "7d"}, logRange) { + return errors.New("invalid log range") + } + return a.runLogs(cmd.Context(), config, value, tail, follow, query, logRange) }, } cmd.Flags().IntVarP(&tail, "tail", "n", defaultLogTail, "Number of log lines to fetch") cmd.Flags().BoolVar(&follow, "follow", true, "Continue polling for new log lines") + cmd.Flags().StringVarP(&query, "query", "q", "", "Search log messages") + cmd.Flags().StringVar(&logRange, "range", "", "Time range (1h, 6h, 24h, 7d)") addServiceTargetFlags(cmd, &target) return cmd } +func (a *App) environmentsCommand() *cobra.Command { + var id string + c := &cobra.Command{Use: "environments", Short: "List project environments", RunE: func(cmd *cobra.Command, args []string) error { + cfg, e := requireConfig() + if e != nil { + 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)") + } + out, e := fetchAllEnvironments(cmd.Context(), a.client(cfg), "/api/v1/projects/"+url.PathEscape(id)+"/environments") + if e != nil { + return e + } + if a.isMachineOutput() { + return a.writeData(out, "Environments") + } + output.Section(a.Out, "Environments") + for _, v := range out.Environments { + fmt.Fprintf(a.Out, " %s %s\n", v.ID, v.Name) + } + return nil + }} + c.Flags().StringVar(&id, "project", "", "Project ID") + return c +} +func (a *App) servicesCommand() *cobra.Command { + var p, eid string + c := &cobra.Command{Use: "services", Short: "List environment services", RunE: func(cmd *cobra.Command, args []string) error { + cfg, e := requireConfig() + if e != nil { + 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)") + } + path := "/api/v1/projects/" + url.PathEscape(p) + "/environments/" + url.PathEscape(eid) + "/services" + out, e := fetchAllServices(cmd.Context(), a.client(cfg), path) + if e != nil { + return e + } + if a.isMachineOutput() { + return a.writeData(out, "Services") + } + output.Section(a.Out, "Services") + for _, v := range out.Services { + fmt.Fprintf(a.Out, " %s %s %s\n", v.ID, v.Name, v.Source.Type) + } + return nil + }} + c.Flags().StringVar(&p, "project", "", "Project ID") + c.Flags().StringVar(&eid, "environment", "", "Environment ID") + return c +} +func (a *App) resourceCommand(name, short, suffix string, q func(*cobra.Command) url.Values) *cobra.Command { + var target serviceTargetFlags + c := &cobra.Command{Use: name, Short: short, RunE: func(cmd *cobra.Command, args []string) error { + cfg, e := requireConfig() + if e != nil { + return e + } + m, e := a.resolveServiceTarget(target) + 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 { + return e + } + label := strings.ToUpper(name[:1]) + name[1:] + if a.isMachineOutput() { + return a.writeData(out, label) + } + output.Section(a.Out, label) + printMapSummary(a.Out, out) + if len(out) > 0 { + raw, _ := json.MarshalIndent(out, " ", " ") + fmt.Fprintln(a.Out, string(raw)) + } + return nil + }} + addServiceTargetFlags(c, &target) + return c +} +func (a *App) paginatedCommand(name, short, suffix string) *cobra.Command { + var limit int + var cursor string + c := a.resourceCommand(name, short, suffix, func(*cobra.Command) url.Values { + q := url.Values{"limit": {strconv.Itoa(limit)}} + if cursor != "" { + q.Set("cursor", cursor) + } + return q + }) + c.Flags().IntVar(&limit, "limit", 25, "Items (1-100)") + c.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor") + c.PreRunE = func(*cobra.Command, []string) error { + if limit < 1 || limit > 100 { + return errors.New("limit must be between 1 and 100") + } + return nil + } + return c +} +func (a *App) rolloutCommand() *cobra.Command { + var target serviceTargetFlags + 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 + logs := &cobra.Command{Use: "logs ", Short: "Show rollout logs", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if limit < 1 || limit > 1000 { + return errors.New("limit must be between 1 and 1000") + } + return a.getRolloutResource(cmd, target, args[0], true, q, limit) + }} + logs.Flags().StringVarP(&q, "query", "q", "", "Search logs") + logs.Flags().IntVar(&limit, "limit", 100, "Log lines") + c.AddCommand(logs) + return c +} +func (a *App) getRolloutResource(cmd *cobra.Command, t serviceTargetFlags, id string, logs bool, q string, limit int) error { + cfg, e := requireConfig() + if e != nil { + return e + } + m, e := a.resolveServiceTarget(t) + if e != nil { + return e + } + suffix := "/rollouts/" + url.PathEscape(id) + query := url.Values{} + if logs { + suffix += "/logs" + query.Set("limit", strconv.Itoa(limit)) + if q != "" { + query.Set("q", q) + } + } + var out map[string]any + if e = a.client(cfg).RequestJSON(cmd.Context(), http.MethodGet, serviceBase(m)+suffix, query, nil, &out); e != nil { + return e + } + if a.isMachineOutput() { + return a.writeData(out, "Rollout") + } + raw, _ := json.MarshalIndent(out, "", " ") + fmt.Fprintln(a.Out, string(raw)) + return nil +} +func (a *App) metricsCommand() *cobra.Command { + var r string + c := a.resourceCommand("metrics", "Show service metrics", "/metrics", func(*cobra.Command) url.Values { return url.Values{"range": {r}} }) + c.Flags().StringVar(&r, "range", "1h", "Range: 1h, 6h, 24h, 7d, 30d") + c.PreRunE = func(*cobra.Command, []string) error { + if !contains([]string{"1h", "6h", "24h", "7d", "30d"}, r) { + return errors.New("invalid metrics range") + } + return nil + } + return c +} +func (a *App) revisionsCommand() *cobra.Command { + var cursor string + c := a.resourceCommand("revisions", "List service revisions", "/revisions", func(*cobra.Command) url.Values { + q := url.Values{} + if cursor != "" { + q.Set("cursor", cursor) + } + return q + }) + c.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor") + return c +} +func contains(v []string, s string) bool { + for _, x := range v { + if x == s { + return true + } + } + return false +} + func (a *App) versionCommand() *cobra.Command { return &cobra.Command{ Use: "version", @@ -736,9 +1069,9 @@ type serviceTargetFlags struct { } func addServiceTargetFlags(cmd *cobra.Command, target *serviceTargetFlags) { - cmd.Flags().StringVar(&target.Project, "project", "", "Project name or slug") - cmd.Flags().StringVar(&target.Environment, "environment", "", "Environment name") - cmd.Flags().StringVar(&target.Service, "service", "", "Service name") + 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) { @@ -756,6 +1089,9 @@ func (a *App) resolveServiceTarget(target serviceTargetFlags) (manifest.Manifest if err != nil { return manifest.Manifest{}, err } + if !loaded.Manifest.Linked() { + return manifest.Manifest{}, errors.New("service is not linked: run `tc link`") + } return loaded.Manifest, nil } if explicitCount != 3 { @@ -763,22 +1099,117 @@ func (a *App) resolveServiceTarget(target serviceTargetFlags) (manifest.Manifest } return manifest.Manifest{ APIVersion: "v1", - Project: project, - Environment: environment, + Project: manifest.Project{ID: project, Slug: project}, + Environment: manifest.Environment{ID: environment, Name: environment}, Service: manifest.Service{ - Name: service, + ID: service, Name: service, }, }, nil } -func serviceTargetFromManifest(value manifest.Manifest) serviceTargetOutput { - return serviceTargetOutput{ - Project: value.Project, - Environment: value.Environment, - Service: value.Service.Name, +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 sourcePatch(source manifest.Source) map[string]any { + if source.Type == "image" { + return map[string]any{"type": "image", "image": source.Image} + } + return map[string]any{ + "type": "github", + "repository": source.Repository, + "branch": source.Branch, + "rootDir": source.RootDir, } } +func pageQuery(cursor string) url.Values { + query := url.Values{"limit": {"100"}} + if cursor != "" { + query.Set("cursor", cursor) + } + return query +} + +func fetchAllProjects(ctx context.Context, client *api.Client) (projectsResponse, error) { + var result projectsResponse + cursor := "" + seen := map[string]struct{}{} + for { + var page projectsResponse + if err := client.RequestJSON(ctx, http.MethodGet, "/api/v1/projects", pageQuery(cursor), nil, &page); err != nil { + return projectsResponse{}, err + } + result.Projects = append(result.Projects, page.Projects...) + if page.NextCursor == "" { + return result, nil + } + if _, exists := seen[page.NextCursor]; exists { + return projectsResponse{}, errors.New("projects API returned a repeated pagination cursor") + } + seen[page.NextCursor] = struct{}{} + cursor = page.NextCursor + } +} + +func fetchAllEnvironments(ctx context.Context, client *api.Client, path string) (environmentsResponse, error) { + var result environmentsResponse + cursor := "" + seen := map[string]struct{}{} + for { + var page environmentsResponse + if err := client.RequestJSON(ctx, http.MethodGet, path, pageQuery(cursor), nil, &page); err != nil { + return environmentsResponse{}, err + } + result.Environments = append(result.Environments, page.Environments...) + if page.NextCursor == "" { + return result, nil + } + if _, exists := seen[page.NextCursor]; exists { + return environmentsResponse{}, errors.New("environments API returned a repeated pagination cursor") + } + seen[page.NextCursor] = struct{}{} + cursor = page.NextCursor + } +} + +func fetchAllServices(ctx context.Context, client *api.Client, path string) (servicesResponse, error) { + var result servicesResponse + cursor := "" + seen := map[string]struct{}{} + for { + var page servicesResponse + if err := client.RequestJSON(ctx, http.MethodGet, path, pageQuery(cursor), nil, &page); err != nil { + return servicesResponse{}, err + } + result.Services = append(result.Services, page.Services...) + if page.NextCursor == "" { + return result, nil + } + if _, exists := seen[page.NextCursor]; exists { + return servicesResponse{}, errors.New("services API returned a repeated pagination cursor") + } + seen[page.NextCursor] = struct{}{} + cursor = page.NextCursor + } +} + +func sourcesEqual(expected, actual manifest.Source) bool { + if expected.Type != actual.Type { + return false + } + if expected.Type == "image" { + return expected.Image == actual.Image + } + if !strings.EqualFold(expected.Repository, actual.Repository) || expected.Branch != actual.Branch { + return false + } + if expected.RootDir == nil || actual.RootDir == nil { + return expected.RootDir == nil && actual.RootDir == nil + } + return *expected.RootDir == *actual.RootDir +} + func requireConfig() (*auth.Config, error) { config, err := auth.ReadConfig() if err != nil { @@ -819,7 +1250,9 @@ func (a *App) runAuthLogin(ctx context.Context, host string) error { if deviceCode.ExpiresIn > 0 && !a.Now().Before(expiresAt) { return errors.New("device authorization expired") } - a.Sleep(interval) + if err := a.sleep(ctx, interval); err != nil { + return err + } if deviceCode.ExpiresIn > 0 && !a.Now().Before(expiresAt) { return errors.New("device authorization expired") } @@ -865,12 +1298,11 @@ func (a *App) runAuthLogin(ctx context.Context, host string) error { machineName, _ := os.Hostname() platform := runtime.GOOS + "/" + runtime.GOARCH var exchange exchangeResponse - if err := api.JSON(ctx, a.HTTPClient, http.MethodPost, host+"/api/v1/cli/auth/exchange", map[string]string{ + if err := api.JSON(ctx, a.HTTPClient, http.MethodPost, host+"/api/v1/api-keys", map[string]string{ "authorization": "Bearer " + accessToken, - }, map[string]string{ - "machineName": machineName, - "platform": platform, - "cliVersion": a.Version, + }, map[string]any{ + "name": cliAPIKeyName(machineName), + "metadata": map[string]string{"machineName": machineName, "platform": platform, "cliVersion": a.Version}, }, &exchange); err != nil { return err } @@ -879,13 +1311,10 @@ func (a *App) runAuthLogin(ctx context.Context, host string) error { APIKey: exchange.APIKey, KeyID: exchange.KeyID, KeyName: exchange.Name, - User: &exchange.User, }); err != nil { return err } output.Section(a.Out, "Signed in") - output.Field(a.Out, "User", exchange.User.Email) - output.Field(a.Out, "Name", exchange.User.Name) output.Field(a.Out, "Host", host) key := "created" if exchange.KeyID != "" { @@ -895,21 +1324,26 @@ func (a *App) runAuthLogin(ctx context.Context, host string) error { return nil } -func (a *App) runLogs(ctx context.Context, config *auth.Config, value manifest.Manifest, tail int, follow bool) error { +func cliAPIKeyName(machineName string) string { + name := strings.TrimSpace("CLI " + machineName) + runes := []rune(name) + if len(runes) > 32 { + name = string(runes[:32]) + } + return name +} + +func (a *App) runLogs(ctx context.Context, config *auth.Config, value manifest.Manifest, tail int, follow bool, search, logRange string) error { client := a.client(config) - result, err := fetchLogs(ctx, client, value, tail, "") + result, err := fetchLogs(ctx, client, value, tail, "", search, logRange) if err != nil { return err } if a.isMachineOutput() { - return a.writeData(logsOutput{ - Target: serviceTargetFromManifest(value), - LoggingEnabled: result.LoggingEnabled, - Logs: result.Logs, - }, "Logs") - } - fmt.Fprintf(a.Out, "%s/%s/%s\n", value.Project, value.Environment, value.Service.Name) - if !result.LoggingEnabled { + return a.writeData(result, "Logs") + } + fmt.Fprintf(a.Out, "%s/%s/%s\n", value.Project.Slug, value.Environment.Name, value.Service.Name) + if result.Provider == "disabled" { output.Section(a.Out, "Logs") output.Field(a.Out, "Status", "disabled") return nil @@ -931,82 +1365,109 @@ func (a *App) runLogs(ctx context.Context, config *auth.Config, value manifest.M output.Field(a.Out, "Waiting", "new log lines") } - after := getLogCursor(result.Logs) - if after == "" { - after = a.Now().UTC().Format(time.RFC3339Nano) + cursor := result.NextCursor + if cursor == "" { + return fmt.Errorf("logs API did not return nextCursor") } for { - a.Sleep(logPollInterval) - next, err := fetchLogs(ctx, client, value, defaultLogTail, after) + next, err := fetchLogs(ctx, client, value, defaultLogTail, cursor, search, logRange) if err != nil { + if errors.Is(err, context.Canceled) { + return nil + } return err } - if len(next.Logs) == 0 { + if len(next.Logs) > 0 { + printLogs(a.Out, next.Logs) + } + if next.NextCursor == "" { + return fmt.Errorf("logs API did not return nextCursor") + } + cursor = next.NextCursor + if next.HasMore { continue } - printLogs(a.Out, next.Logs) - if cursor := getLogCursor(next.Logs); cursor != "" { - after = cursor + d := time.Duration(next.PollAfterMS) * time.Millisecond + if d <= 0 { + d = logPollInterval + } + if err := a.sleep(ctx, d); err != nil { + if errors.Is(err, context.Canceled) { + return nil + } + return err } } } -func fetchLogs(ctx context.Context, client *api.Client, value manifest.Manifest, tail int, after string) (logsResponse, error) { - query := manifestIdentityQuery(value) - query.Set("tail", strconv.Itoa(tail)) - if after != "" { - query.Set("after", after) +func (a *App) sleep(ctx context.Context, duration time.Duration) error { + if err := ctx.Err(); err != nil { + return err + } + if a.Sleep != nil { + a.Sleep(duration) + return ctx.Err() + } + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil } - var result logsResponse - err := client.RequestJSON(ctx, http.MethodGet, "/api/v1/manifest/logs", query, nil, &result) - return result, err } -func manifestIdentityQuery(value manifest.Manifest) url.Values { - return url.Values{ - "project": {value.Project}, - "environment": {value.Environment}, - "service": {value.Service.Name}, +func fetchLogs(ctx context.Context, client *api.Client, value manifest.Manifest, tail int, cursor, search, logRange string) (logsResponse, error) { + query := url.Values{} + query.Set("tail", strconv.Itoa(tail)) + if search != "" { + query.Set("q", search) } + if logRange != "" { + query.Set("range", logRange) + } + if cursor != "" { + query.Set("cursor", cursor) + query.Set("wait", "20") + } + var result logsResponse + err := client.RequestJSON(ctx, http.MethodGet, serviceBase(value)+"/logs", query, nil, &result) + return result, err } func printApplyResult(w io.Writer, result applyResponse) { output.Section(w, "Apply") output.Field(w, "Action", result.Action) - output.Field(w, "Service", output.ShortID(result.ServiceID)) if len(result.Changes) == 0 { output.Field(w, "Changes", "none") return } output.Section(w, fmt.Sprintf("Changes (%d)", len(result.Changes))) for _, change := range result.Changes { - fmt.Fprintf(w, " * %s\n", change.Field) - output.Field(w, "From", change.From) - output.Field(w, "To", change.To) + fmt.Fprintf(w, " * %s\n", change) } } func printStatus(w io.Writer, value manifest.Manifest, status statusResponse) { - fmt.Fprintf(w, "%s/%s/%s\n", value.Project, value.Environment, value.Service.Name) + fmt.Fprintf(w, "%s/%s/%s\n", value.Project.Slug, value.Environment.Name, value.Service.Name) output.Section(w, "Service") output.Field(w, "ID", output.ShortID(status.Service.ID)) - output.Field(w, "Image", status.Service.Image) - if status.Service.Hostname == nil || *status.Service.Hostname == "" { - output.Field(w, "Hostname", "none") + if status.Service.Source.Type == "image" { + output.Field(w, "Source", status.Service.Source.Image) } else { - output.Field(w, "Hostname", *status.Service.Hostname) + output.Field(w, "Source", status.Service.Source.Repository+" @ "+status.Service.Source.Branch) + } + output.Section(w, "Build") + if status.LatestBuild == nil { + output.Field(w, "Latest", "none") + } else { + printMapSummary(w, status.LatestBuild) } - output.Field(w, "Replicas", status.Service.Replicas) output.Section(w, "Rollout") if status.LatestRollout != nil { - output.Field(w, "ID", output.ShortID(status.LatestRollout.ID)) - output.Field(w, "Status", output.Status(status.LatestRollout.Status)) - if status.LatestRollout.CurrentStage != nil && *status.LatestRollout.CurrentStage != "" { - output.Field(w, "Stage", output.Status(*status.LatestRollout.CurrentStage)) - } else { - output.Field(w, "Stage", "none") - } + printMapSummary(w, status.LatestRollout) } else { output.Field(w, "Latest", "none") } @@ -1017,9 +1478,15 @@ func printStatus(w io.Writer, value manifest.Manifest, status statusResponse) { return } for _, deployment := range status.Deployments { - fmt.Fprintf(w, " * %s\n", output.ShortID(deployment.ID)) - output.Field(w, "Status", output.Status(deployment.Status)) - output.Field(w, "Server", output.ShortID(deployment.ServerID)) + printMapSummary(w, deployment) + } +} + +func printMapSummary(w io.Writer, m map[string]any) { + for _, k := range []string{"id", "status", "phase", "currentStage", "serverName", "createdAt"} { + if v, ok := m[k]; ok && v != nil { + output.Field(w, k, v) + } } } @@ -1034,28 +1501,6 @@ func printLogs(w io.Writer, logs []serviceLog) { } } -func getLogCursor(logs []serviceLog) string { - var latest string - var latestTime time.Time - for _, log := range logs { - parsed, err := time.Parse(time.RFC3339Nano, log.Timestamp) - if err != nil { - continue - } - if latest == "" || parsed.After(latestTime) { - latest = log.Timestamp - latestTime = parsed - } - } - if latest != "" { - return latest - } - if len(logs) > 0 { - return logs[len(logs)-1].Timestamp - } - return "" -} - func selectFromList[T any]( reader *bufio.Reader, out io.Writer, diff --git a/cli/internal/cli/app_test.go b/cli/internal/cli/app_test.go index f5c253c4..e41beafc 100644 --- a/cli/internal/cli/app_test.go +++ b/cli/internal/cli/app_test.go @@ -4,607 +4,456 @@ import ( "bytes" "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "os" "path/filepath" + "reflect" "strings" "testing" "time" + "techulus/cloud-cli/internal/api" "techulus/cloud-cli/internal/auth" "techulus/cloud-cli/internal/manifest" ) -func TestInitCreatesManifest(t *testing.T) { - tmp := t.TempDir() - stdout, stderr, err := runTestCommand(t, nil, tmp, "init") - if err != nil { - t.Fatalf("init error = %v\nstderr=%s", err, stderr) - } - if !strings.Contains(stdout, "tc apply") { - t.Fatalf("stdout = %s", stdout) - } - raw, err := os.ReadFile(filepath.Join(tmp, "techulus.yml")) - if err != nil { - t.Fatalf("read manifest: %v", err) - } - if !strings.Contains(string(raw), "image: nginx:1.27") { - t.Fatalf("manifest = %s", raw) - } - if strings.Contains(string(raw), "resources:") { - t.Fatalf("manifest should not set default resource limits: %s", raw) - } -} - -func TestLogsRejectsInvalidTailBeforeConfig(t *testing.T) { - _, _, err := runTestCommand(t, nil, t.TempDir(), "logs", "--tail", "0") - if err == nil || !strings.Contains(err.Error(), "between 1 and 1000") { - t.Fatalf("error = %v", err) - } -} - -func TestAgentHelpOutputsStructuredCommandMetadata(t *testing.T) { - stdout, stderr, err := runTestCommand(t, nil, t.TempDir(), "status", "--help", "--agent") - if err != nil { - t.Fatalf("help error = %v\nstderr=%s", err, stderr) - } - var help agentHelpInfo - if err := json.Unmarshal([]byte(stdout), &help); err != nil { - t.Fatalf("decode help: %v\nstdout=%s", err, stdout) - } - if help.Command != "status" || help.Path != "tc status" { - t.Fatalf("help = %#v", help) - } - if !agentFlagsContain(help.Flags, "project") || !agentFlagsContain(help.InheritedFlags, "agent") { - t.Fatalf("flags = %#v inherited = %#v", help.Flags, help.InheritedFlags) - } - if len(help.Notes) == 0 || !strings.Contains(strings.Join(help.Notes, "\n"), "explicit target flags") { - t.Fatalf("notes = %#v", help.Notes) - } -} - -func TestAgentCompletionHelpOutputsChoiceArg(t *testing.T) { - stdout, stderr, err := runTestCommand(t, nil, t.TempDir(), "completion", "--help", "--agent") - if err != nil { - t.Fatalf("help error = %v\nstderr=%s", err, stderr) - } - var help agentHelpInfo - if err := json.Unmarshal([]byte(stdout), &help); err != nil { - t.Fatalf("decode help: %v\nstdout=%s", err, stdout) - } - if len(help.Args) != 1 { - t.Fatalf("args = %#v", help.Args) - } - arg := help.Args[0] - if arg.Name != "shell" || !arg.Required { - t.Fatalf("arg = %#v", arg) - } - wantChoices := []string{"bash", "zsh", "fish", "powershell"} - if strings.Join(arg.Choices, ",") != strings.Join(wantChoices, ",") { - t.Fatalf("choices = %#v", arg.Choices) - } -} - -func TestJSONHelpOutputsEnvelope(t *testing.T) { - stdout, stderr, err := runTestCommand(t, nil, t.TempDir(), "status", "--help", "--json") - if err != nil { - t.Fatalf("help error = %v\nstderr=%s", err, stderr) - } - var envelope struct { - OK bool `json:"ok"` - Data agentHelpInfo `json:"data"` - Summary string `json:"summary"` - } - if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { - t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) - } - if !envelope.OK || envelope.Summary != "Help" { - t.Fatalf("envelope = %#v", envelope) - } - if envelope.Data.Command != "status" || envelope.Data.Path != "tc status" { - t.Fatalf("data = %#v", envelope.Data) - } -} +const imageManifest = `apiVersion: v1 +project: {id: p, slug: app} +environment: {id: e, name: prod} +service: + id: s + name: web + source: {type: image, image: nginx:1.27} + replicas: 2 + hostname: null + healthCheck: null + startCommand: null + ports: [] +` -func TestAgentStatusOutputsRawJSON(t *testing.T) { - tmp := t.TempDir() +func TestAuthDeviceExchangeCreatesAPIKeyAndWhoamiUsesIt(t *testing.T) { + configHome(t) + polls := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/manifest/status" { - t.Fatalf("path = %s", r.URL.Path) + switch r.URL.Path { + case "/api/auth/device/code": + json.NewEncoder(w).Encode(deviceCodeResponse{DeviceCode: "device", UserCode: "ABCD", VerificationURI: "https://verify", ExpiresIn: 60, Interval: 1}) + case "/api/auth/device/token": + polls++ + json.NewEncoder(w).Encode(deviceTokenResponse{AccessToken: "access"}) + case "/api/v1/api-keys": + if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer access" { + t.Errorf("exchange request = %s auth=%q", r.Method, r.Header.Get("Authorization")) + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + if body["name"] == "" || body["metadata"] == nil { + t.Errorf("exchange body = %#v", body) + } + w.Write([]byte(`{"apiKey":"new-secret","keyId":"key-id","name":"CLI test"}`)) + case "/api/v1/me": + if r.Header.Get("X-API-Key") != "new-secret" { + t.Errorf("X-API-Key = %q", r.Header.Get("X-API-Key")) + } + w.Write([]byte(`{"user":{"id":"u","email":"a@example.com","name":"Alice"}}`)) + default: + t.Errorf("unexpected path %s", r.URL.Path) } - w.Write([]byte(`{"service":{"id":"1234567890abcdef","name":"web","image":"nginx:1.27","hostname":null,"replicas":1},"latestRollout":null,"deployments":[]}`)) })) defer server.Close() - writeTestConfig(t, server.URL) - stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "--agent", "status", "--project", "app", "--environment", "production", "--service", "web") - if err != nil { - t.Fatalf("status error = %v\nstderr=%s", err, stderr) + app, out := testApp(t, t.TempDir(), server.Client()) + app.Sleep = func(time.Duration) {} + if err := execute(app, "auth", "login", "--host", server.URL); err != nil { + t.Fatal(err) } - var raw map[string]any - if err := json.Unmarshal([]byte(stdout), &raw); err != nil { - t.Fatalf("decode raw: %v\nstdout=%s", err, stdout) + cfg, _ := auth.ReadConfig() + if polls != 1 || cfg == nil || cfg.APIKey != "new-secret" || cfg.KeyID != "key-id" { + t.Fatalf("polls=%d config=%#v", polls, cfg) } - if _, ok := raw["ok"]; ok { - t.Fatalf("agent output should be raw data, got %s", stdout) + out.Reset() + if err := execute(app, "auth", "whoami"); err != nil { + t.Fatal(err) } - target := raw["target"].(map[string]any) - if target["project"] != "app" || raw["status"] == nil { - t.Fatalf("raw = %#v", raw) + if !strings.Contains(out.String(), "a@example.com") || !strings.Contains(out.String(), server.URL) { + t.Fatalf("whoami output = %s", out.String()) } } -func TestJSONStatusOutputsEnvelope(t *testing.T) { - tmp := t.TempDir() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/manifest/status" { - t.Fatalf("path = %s", r.URL.Path) - } - w.Write([]byte(`{"service":{"id":"1234567890abcdef","name":"web","image":"nginx:1.27","hostname":null,"replicas":1},"latestRollout":null,"deployments":[]}`)) - })) - defer server.Close() - writeTestConfig(t, server.URL) - - stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "--json", "status", "--project", "app", "--environment", "production", "--service", "web") - if err != nil { - t.Fatalf("status error = %v\nstderr=%s", err, stderr) - } - var envelope map[string]any - if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { - t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) - } - if envelope["ok"] != true || envelope["data"] == nil || envelope["summary"] != "Status" { - t.Fatalf("envelope = %#v", envelope) +func TestInitRecommendsLinkInHumanAndJSON(t *testing.T) { + for _, mode := range []string{"human", "json", "agent"} { + t.Run(mode, func(t *testing.T) { + d := t.TempDir() + app, out := testApp(t, d, nil) + args := []string{"init"} + if mode != "human" { + args = append([]string{"--" + mode}, args...) + } + if err := execute(app, args...); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "tc link") { + t.Fatalf("output = %s", out.String()) + } + if _, err := os.Stat(filepath.Join(d, "techulus.yml")); err != nil { + t.Fatal(err) + } + }) } } -func TestAgentLogsOutputsOneShotJSON(t *testing.T) { - tmp := t.TempDir() - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/manifest/logs" { - t.Fatalf("path = %s", r.URL.Path) +func TestLinkByIDsFetchesConfigurationAndSupportsPublicGitHub(t *testing.T) { + configHome(t) + root := "cmd/api" + var paths []string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/api/v1/projects": + w.Write([]byte(`{"projects":[{"id":"p","name":"App","slug":"app"}]}`)) + case "/api/v1/projects/p/environments": + 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,"hostname":null,"ports":[],"healthCheck":null,"startCommand":null},"management":{"patchable":true,"blockers":[]}}`)) + default: + t.Errorf("path=%s", r.URL.Path) } - requests++ - w.Write([]byte(`{"loggingEnabled":true,"logs":[{"deploymentId":"d","stream":"stdout","message":"hello","timestamp":"2026-01-01T00:00:00Z"}]}`)) })) - defer server.Close() - writeTestConfig(t, server.URL) - - stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "--agent", "logs", "--project", "app", "--environment", "production", "--service", "web") + 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 { + t.Fatal(err) + } + loaded, err := manifest.Load(d) if err != nil { - t.Fatalf("logs error = %v\nstderr=%s", err, stderr) - } - var result logsOutput - if err := json.Unmarshal([]byte(stdout), &result); err != nil { - t.Fatalf("decode logs: %v\nstdout=%s", err, stdout) - } - if requests != 1 || !result.LoggingEnabled || len(result.Logs) != 1 || result.Logs[0].Message != "hello" { - t.Fatalf("requests=%d result=%#v", requests, result) - } -} - -func TestAgentLogsRejectsFollowTrue(t *testing.T) { - _, _, err := runTestCommand(t, nil, t.TempDir(), "--agent", "logs", "--project", "app", "--environment", "production", "--service", "web", "--follow=true") - if err == nil || !strings.Contains(err.Error(), "--follow=true is not supported") { - t.Fatalf("error = %v", err) - } -} - -func TestExecuteWritesMachineErrorEnvelope(t *testing.T) { - tmp := t.TempDir() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected API request: %s", r.URL.Path) - })) - defer server.Close() - writeTestConfig(t, server.URL) - - stdout, stderr, err := runTestAppExecute(t, server.Client(), tmp, "--json", "status", "--project", "app") - if err == nil { - t.Fatal("expected error") - } - if !IsHandledError(err) { - t.Fatalf("error should be marked handled, got %T %v", err, err) - } - if stderr != "" { - t.Fatalf("stderr = %q", stderr) + t.Fatal(err) } - var envelope map[string]any - if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { - t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + if loaded.Manifest.Service.Source.Repository != "https://github.com/acme/public" || loaded.Manifest.Service.Source.RootDir == nil || *loaded.Manifest.Service.Source.RootDir != root || loaded.Manifest.Service.Replicas != 2 { + t.Fatalf("manifest=%#v", loaded.Manifest) } - if envelope["ok"] != false || !strings.Contains(envelope["error"].(string), "provide --project") { - t.Fatalf("envelope = %#v", envelope) + 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"} + if !reflect.DeepEqual(paths, want) { + t.Fatalf("paths=%v", paths) } } -func TestHandledErrorUnwrapsOriginalError(t *testing.T) { - base := errors.New("base") - wrapped := handledError{err: base} - if !errors.Is(wrapped, base) { - t.Fatalf("handledError should unwrap original error") - } -} - -func TestAuthLoginRejectsMachineOutputBeforeWritingHumanText(t *testing.T) { - stdout, stderr, err := runTestAppExecute(t, nil, t.TempDir(), "--agent", "auth", "login", "--host", "https://example.com") - if err == nil { - t.Fatal("expected error") - } - if stderr != "" { - t.Fatalf("stderr = %q", stderr) - } - var envelope map[string]any - if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { - t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) - } - if envelope["ok"] != false || !strings.Contains(envelope["error"].(string), "requires human browser approval") { - t.Fatalf("envelope = %#v", envelope) - } -} - -func TestLinkRejectsMachineOutputWithSpecificMessage(t *testing.T) { - stdout, stderr, err := runTestAppExecute(t, nil, t.TempDir(), "--json", "link") - if err == nil { - t.Fatal("expected error") - } - if stderr != "" { - t.Fatalf("stderr = %q", stderr) - } - var envelope map[string]any - if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { - t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) - } - if envelope["ok"] != false || !strings.Contains(envelope["error"].(string), "does not support --agent or --json") { - t.Fatalf("envelope = %#v", envelope) +func TestApplyExactNestedPatchForSources(t *testing.T) { + for _, tc := range []struct{ name, source, sourceType string }{ + {"image", "{type: image, image: nginx:1.27}", "image"}, + {"github", "{type: github, repository: https://github.com/acme/repo, branch: main, rootDir: cmd/api}", "github"}, + {"github_clear_root", "{type: github, repository: https://github.com/acme/repo, branch: main}", "github"}, + } { + t.Run(tc.name, func(t *testing.T) { + configHome(t) + d := t.TempDir() + writeManifest(t, d, strings.Replace(imageManifest, "{type: image, image: nginx:1.27}", tc.source, 1)) + var method, path string + var body map[string]any + 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"]}`)) + })) + defer s.Close() + writeConfig(t, s.URL) + app, _ := testApp(t, d, s.Client()) + if err := execute(app, "apply"); err != nil { + t.Fatal(err) + } + if method != "PATCH" || path != "/api/v1/projects/p/environments/e/services/s/configuration" { + t.Fatalf("%s %s", method, path) + } + source := body["source"].(map[string]any) + if source["type"] != tc.sourceType || body["replicas"] != float64(2) || len(body) != 6 { + t.Fatalf("body=%#v", body) + } + if tc.name == "github_clear_root" { + rootDir, present := source["rootDir"] + if !present || rootDir != nil { + t.Fatalf("GitHub rootDir must be sent as explicit null: %#v", source) + } + } + }) } } -func TestStatusUsesExplicitTargetWithoutManifest(t *testing.T) { - tmp := t.TempDir() - var sawStatus bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/manifest/status" { - t.Fatalf("path = %s", r.URL.Path) +func TestCollectionRequestsFollowPagination(t *testing.T) { + queries := map[string][]string{} + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cursor := r.URL.Query().Get("cursor") + queries[r.URL.Path] = append(queries[r.URL.Path], cursor) + if r.URL.Query().Get("limit") != "100" { + t.Errorf("limit=%q", r.URL.Query().Get("limit")) } - query := r.URL.Query() - if query.Get("project") != "app" || query.Get("environment") != "production" || query.Get("service") != "web" { - t.Fatalf("query = %s", r.URL.RawQuery) + switch r.URL.Path { + case "/api/v1/projects": + if cursor == "" { + w.Write([]byte(`{"projects":[{"id":"p1","name":"One","slug":"one"}],"nextCursor":"projects-next"}`)) + } else { + w.Write([]byte(`{"projects":[{"id":"p2","name":"Two","slug":"two"}],"nextCursor":null}`)) + } + case "/environments": + if cursor == "" { + w.Write([]byte(`{"environments":[{"id":"e1","name":"One"}],"nextCursor":"environments-next"}`)) + } else { + w.Write([]byte(`{"environments":[{"id":"e2","name":"Two"}],"nextCursor":null}`)) + } + case "/services": + if cursor == "" { + w.Write([]byte(`{"services":[{"id":"s1","name":"One","source":{"type":"image","image":"one"}}],"nextCursor":"services-next"}`)) + } else { + w.Write([]byte(`{"services":[{"id":"s2","name":"Two","source":{"type":"image","image":"two"}}],"nextCursor":null}`)) + } + default: + t.Errorf("path=%s", r.URL.Path) } - sawStatus = true - w.Write([]byte(`{"service":{"id":"1234567890abcdef","name":"web","image":"nginx:1.27","hostname":null,"replicas":1},"latestRollout":null,"deployments":[]}`)) })) - defer server.Close() - writeTestConfig(t, server.URL) + defer s.Close() + client := api.NewClient(s.URL, "secret") + client.HTTPClient = s.Client() - stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "status", "--project", "app", "--environment", "production", "--service", "web") + projects, err := fetchAllProjects(context.Background(), client) if err != nil { - t.Fatalf("status error = %v\nstderr=%s", err, stderr) - } - if !sawStatus || !strings.Contains(stdout, "app/production/web") || !strings.Contains(stdout, "nginx:1.27") { - t.Fatalf("stdout = %s sawStatus=%v", stdout, sawStatus) + t.Fatal(err) } -} - -func TestLogsUsesExplicitTargetWithoutManifest(t *testing.T) { - tmp := t.TempDir() - var sawLogs bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/manifest/logs" { - t.Fatalf("path = %s", r.URL.Path) - } - query := r.URL.Query() - if query.Get("project") != "app" || query.Get("environment") != "production" || query.Get("service") != "web" || query.Get("tail") != "10" { - t.Fatalf("query = %s", r.URL.RawQuery) - } - sawLogs = true - w.Write([]byte(`{"loggingEnabled":true,"logs":[{"deploymentId":"d","stream":"stdout","message":"hello","timestamp":"2026-01-01T00:00:00Z"}]}`)) - })) - defer server.Close() - writeTestConfig(t, server.URL) - - stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "logs", "--project", "app", "--environment", "production", "--service", "web", "--tail", "10", "--follow=false") + environments, err := fetchAllEnvironments(context.Background(), client, "/environments") if err != nil { - t.Fatalf("logs error = %v\nstderr=%s", err, stderr) + t.Fatal(err) } - if !sawLogs || !strings.Contains(stdout, "app/production/web") || !strings.Contains(stdout, "hello") { - t.Fatalf("stdout = %s sawLogs=%v", stdout, sawLogs) + services, err := fetchAllServices(context.Background(), client, "/services") + if err != nil { + t.Fatal(err) + } + if len(projects.Projects) != 2 || len(environments.Environments) != 2 || len(services.Services) != 2 { + t.Fatalf("projects=%#v environments=%#v services=%#v", projects, environments, services) + } + for path, want := range map[string][]string{ + "/api/v1/projects": {"", "projects-next"}, + "/environments": {"", "environments-next"}, + "/services": {"", "services-next"}, + } { + if !reflect.DeepEqual(queries[path], want) { + t.Fatalf("%s queries=%v", path, queries[path]) + } } } -func TestReadOnlyTargetsRejectPartialFlags(t *testing.T) { - tmp := t.TempDir() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected API request: %s", r.URL.Path) - })) - defer server.Close() - writeTestConfig(t, server.URL) - - _, _, err := runTestCommand(t, server.Client(), tmp, "status", "--project", "app") - if err == nil || !strings.Contains(err.Error(), "provide --project, --environment, and --service together") { - t.Fatalf("status error = %v", err) - } - - _, _, err = runTestCommand(t, server.Client(), tmp, "logs", "--project", "app", "--service", "web", "--follow=false") - if err == nil || !strings.Contains(err.Error(), "provide --project, --environment, and --service together") { - t.Fatalf("logs error = %v", err) +func TestMissingIDsFailLocally(t *testing.T) { + configHome(t) + writeConfig(t, "http://unused") + 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, ", "")) + app, _ := testApp(t, d, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("unexpected network request") + return nil, nil + })}) + err := execute(app, command) + if err == nil || !strings.Contains(err.Error(), "tc link") { + t.Fatalf("error=%v", err) + } + }) } } -func TestApplyPostsManifest(t *testing.T) { - tmp := t.TempDir() - writeTestManifest(t, tmp) - var sawApply bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/manifest/apply" { - t.Fatalf("path = %s", r.URL.Path) - } - if r.Header.Get("x-api-key") != "secret" { - t.Fatalf("api key = %q", r.Header.Get("x-api-key")) - } - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode body: %v", err) - } - if body["project"] != "app" { - t.Fatalf("body = %#v", body) - } - sawApply = true - w.Write([]byte(`{"action":"updated","serviceId":"1234567890abcdef","changes":[]}`)) - })) - defer server.Close() - writeTestConfig(t, server.URL) - - stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "apply") - if err != nil { - t.Fatalf("apply error = %v\nstderr=%s", err, stderr) - } - if !sawApply || !strings.Contains(stdout, "Action updated") { - t.Fatalf("stdout = %s sawApply=%v", stdout, sawApply) +func TestDeploySourceNeutralAndMismatch(t *testing.T) { + for _, tc := range []struct { + name, local, persisted string + mismatch bool + }{ + {"image", `{type: image, image: nginx:1.27}`, `{"type":"image","image":"nginx:1.27"}`, false}, + {"github", `{type: github, repository: https://github.com/acme/repo, branch: main}`, `{"type":"github","repository":"https://github.com/acme/repo","branch":"main"}`, false}, + {"github repository casing", `{type: github, repository: https://github.com/Acme/Repo, branch: main}`, `{"type":"github","repository":"https://github.com/acme/repo","branch":"main"}`, false}, + {"mismatch", `{type: image, image: nginx:1.27}`, `{"type":"image","image":"nginx:latest"}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + configHome(t) + d := t.TempDir() + writeManifest(t, d, strings.Replace(imageManifest, `{type: image, image: nginx:1.27}`, tc.local, 1)) + posts := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Write([]byte(`{"current":{"source":` + tc.persisted + `}}`)) + return + } + posts++ + w.Write([]byte(`{"operation":"rollout","status":"queued"}`)) + })) + defer s.Close() + writeConfig(t, s.URL) + app, _ := testApp(t, d, s.Client()) + err := execute(app, "deploy") + if tc.mismatch { + if err == nil || !strings.Contains(err.Error(), "tc apply") || posts != 0 { + t.Fatalf("err=%v posts=%d", err, posts) + } + } else if err != nil || posts != 1 { + t.Fatalf("err=%v posts=%d", err, posts) + } + }) } } -func TestAuthLoginDeviceFlow(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "config")) - polls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/auth/device/code": - w.Write([]byte(`{"device_code":"device","user_code":"ABCD","verification_uri":"https://verify","verification_uri_complete":"https://verify?code=ABCD","expires_in":600,"interval":1}`)) - case "/api/auth/device/token": - polls++ - if polls == 1 { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"error":"authorization_pending"}`)) - return +func TestStatusAndResourceRoutesAndOutput(t *testing.T) { + commands := []struct { + args []string + path, query string + }{ + {[]string{"status"}, "/status", ""}, + {[]string{"rollouts", "--limit", "7", "--cursor", "next"}, "/rollouts", "cursor=next&limit=7"}, + {[]string{"rollout", "r1"}, "/rollouts/r1", ""}, + {[]string{"rollout", "logs", "r1", "-q", "oops", "--limit", "9"}, "/rollouts/r1/logs", "limit=9&q=oops"}, + {[]string{"builds", "--limit", "3"}, "/builds", "limit=3"}, + {[]string{"metrics", "--range", "24h"}, "/metrics", "range=24h"}, + {[]string{"revisions", "--cursor", "rev"}, "/revisions", "cursor=rev"}, + } + for _, tc := range commands { + t.Run(strings.Join(tc.args, "_"), func(t *testing.T) { + configHome(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":"s","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") + if err := execute(app, args...); err != nil { + t.Fatal(err) + } + var value any + if json.Unmarshal(out.Bytes(), &value) != nil { + t.Fatalf("invalid JSON: %s", out.String()) + } } - w.Write([]byte(`{"access_token":"access"}`)) - case "/api/v1/cli/auth/exchange": - if r.Header.Get("authorization") != "Bearer access" { - t.Fatalf("authorization = %q", r.Header.Get("authorization")) + base := "/api/v1/projects/p/environments/e/services/s" + if gotPath != base+tc.path || gotQuery != tc.query { + t.Fatalf("got %s?%s", gotPath, gotQuery) } - w.Write([]byte(`{"apiKey":"secret","keyId":"key-123456789","name":"CLI","user":{"id":"user","email":"a@example.com","name":"Alice"}}`)) - default: - t.Fatalf("path = %s", r.URL.Path) - } - })) - defer server.Close() - - var stdout bytes.Buffer - var stderr bytes.Buffer - app := NewApp("test", strings.NewReader(""), &stdout, &stderr) - app.HTTPClient = server.Client() - app.Sleep = func(time.Duration) {} - app.GetCWD = func() (string, error) { return tmp, nil } - cmd := app.rootCommand() - cmd.SetArgs([]string{"auth", "login", "--host", server.URL}) - cmd.SetIn(app.In) - cmd.SetOut(app.Out) - cmd.SetErr(app.Err) - if err := cmd.Execute(); err != nil { - t.Fatalf("auth login error = %v\nstderr=%s", err, stderr.String()) - } - config, err := auth.ReadConfig() - if err != nil { - t.Fatalf("ReadConfig() error = %v", err) - } - if config == nil || config.APIKey != "secret" || config.Host != server.URL { - t.Fatalf("config = %#v", config) - } - if !strings.Contains(stdout.String(), "Signed in") { - t.Fatalf("stdout = %s", stdout.String()) + }) } } -func TestAuthLoginStopsAtDeviceExpiry(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "config")) - polls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/auth/device/code": - w.Write([]byte(`{"device_code":"device","user_code":"ABCD","verification_uri":"https://verify","expires_in":2,"interval":1}`)) - case "/api/auth/device/token": - polls++ - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"error":"authorization_pending"}`)) - default: - t.Fatalf("path = %s", r.URL.Path) +func TestLogsQueryCursorLongPollAndCancellation(t *testing.T) { + configHome(t) + requests := 0 + ctx, cancel := context.WithCancel(context.Background()) + var queries []string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + queries = append(queries, r.URL.RawQuery) + if requests == 1 { + w.Write([]byte(`{"provider":"enabled","logs":[{"stream":"stdout","message":"one","timestamp":"2026-01-01T00:00:00Z"}],"nextCursor":"opaque_cursor-1"}`)) + return } - })) - defer server.Close() - - now := time.Unix(0, 0) - var stdout bytes.Buffer - var stderr bytes.Buffer - app := NewApp("test", strings.NewReader(""), &stdout, &stderr) - app.HTTPClient = server.Client() - app.Now = func() time.Time { return now } - app.Sleep = func(duration time.Duration) { now = now.Add(duration) } - app.GetCWD = func() (string, error) { return tmp, nil } - cmd := app.rootCommand() - cmd.SetArgs([]string{"auth", "login", "--host", server.URL}) - cmd.SetIn(app.In) - cmd.SetOut(app.Out) - cmd.SetErr(app.Err) - err := cmd.Execute() - if err == nil || !strings.Contains(err.Error(), "device authorization expired") { - t.Fatalf("auth login error = %v", err) - } - if polls != 1 { - t.Fatalf("polls = %d, want 1", polls) - } -} - -func TestLinkInteractiveFlow(t *testing.T) { - tmp := t.TempDir() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v1/manifest/link-targets": - w.Write([]byte(`{"projects":[{"id":"p","name":"Project","slug":"project","environments":[{"id":"e","name":"production","services":[{"id":"s1","name":"db","project":"Project","environment":"production","linkSupported":false,"unsupportedReason":"stateful"},{"id":"s2","name":"web","project":"Project","environment":"production","linkSupported":true,"unsupportedReason":null}]}]}]}`)) - case "/api/v1/manifest/link": - w.Write([]byte(`{"manifest":{"apiVersion":"v1","project":"Project","environment":"production","service":{"name":"web","source":{"type":"image","image":"nginx"},"replicas":{"count":1},"ports":[]}},"service":{"id":"s2","name":"web","project":"Project","environment":"production"}}`)) - default: - t.Fatalf("path = %s", r.URL.Path) + if r.URL.Query().Get("wait") != "20" { + t.Errorf("wait=%q", r.URL.Query().Get("wait")) } + cancel() + <-r.Context().Done() })) - defer server.Close() - writeTestConfig(t, server.URL) - - stdout, stderr, err := runTestCommandWithInput(t, server.Client(), tmp, "1\n1\n1\n2\n", true, "link") + defer s.Close() + client := s.Client() + client.Timeout = 30 * time.Second + app, _ := testApp(t, t.TempDir(), client) + err := app.runLogs(ctx, &auth.Config{Host: s.URL, APIKey: "secret"}, targetManifest(), 12, true, "needle", "6h") if err != nil { - t.Fatalf("link error = %v\nstderr=%s\nstdout=%s", err, stderr, stdout) - } - if !strings.Contains(stdout, "stateful") || !strings.Contains(stdout, "Linked") { - t.Fatalf("stdout = %s", stdout) + t.Fatal(err) } - if _, err := os.Stat(filepath.Join(tmp, "techulus.yml")); err != nil { - t.Fatalf("manifest not written: %v", err) + if requests != 2 || !strings.Contains(queries[0], "q=needle") || !strings.Contains(queries[0], "range=6h") || !strings.Contains(queries[1], "cursor=opaque_cursor-1") { + t.Fatalf("requests=%d queries=%v", requests, queries) } } -func TestRunLogsFollowPrintsDuplicateReturnedLines(t *testing.T) { - tmp := t.TempDir() - writeTestManifest(t, tmp) +func TestLogsDrainAvailablePagesWithoutSleeping(t *testing.T) { + configHome(t) requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/manifest/logs" { - t.Fatalf("path = %s", r.URL.Path) - } + ctx, cancel := context.WithCancel(context.Background()) + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ switch requests { case 1: - w.Write([]byte(`{"loggingEnabled":true,"logs":[]}`)) + w.Write([]byte(`{"provider":"enabled","logs":[],"nextCursor":"page-1"}`)) case 2: - w.Write([]byte(`{"loggingEnabled":true,"logs":[{"deploymentId":"d","stream":"stdout","message":"same","timestamp":"2026-01-01T00:00:00Z"},{"deploymentId":"d","stream":"stdout","message":"same","timestamp":"2026-01-01T00:00:00Z"}]}`)) + if got := r.URL.Query().Get("cursor"); got != "page-1" { + t.Errorf("cursor=%q", got) + } + w.Write([]byte(`{"provider":"enabled","logs":[{"stream":"stdout","message":"same-time-a","timestamp":"2026-01-01T00:00:00Z"},{"stream":"stdout","message":"same-time-b","timestamp":"2026-01-01T00:00:00Z"}],"nextCursor":"page-2","hasMore":true,"pollAfterMs":9999}`)) + case 3: + if got := r.URL.Query().Get("cursor"); got != "page-2" { + t.Errorf("cursor=%q", got) + } + cancel() + <-r.Context().Done() default: - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error":"stop"}`)) + t.Errorf("unexpected request %d", requests) } })) - defer server.Close() + defer s.Close() - var stdout bytes.Buffer - app := NewApp("test", strings.NewReader(""), &stdout, &bytes.Buffer{}) - app.HTTPClient = server.Client() - app.Sleep = func(time.Duration) {} - app.Now = func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) } - err := app.runLogs(context.Background(), &auth.Config{Host: server.URL, APIKey: "secret"}, testManifest(), 100, true) - if err == nil || !strings.Contains(err.Error(), "stop") { - t.Fatalf("runLogs error = %v", err) + app, out := testApp(t, t.TempDir(), s.Client()) + sleeps := 0 + app.Sleep = func(time.Duration) { sleeps++ } + err := app.runLogs(ctx, &auth.Config{Host: s.URL, APIKey: "secret"}, targetManifest(), 12, true, "", "") + if err != nil { + t.Fatal(err) } - if count := strings.Count(stdout.String(), " same\n"); count != 2 { - t.Fatalf("printed duplicate count = %d, stdout = %s", count, stdout.String()) + if requests != 3 || sleeps != 0 { + t.Fatalf("requests=%d sleeps=%d", requests, sleeps) } -} - -func runTestCommand(t *testing.T, client *http.Client, cwd string, args ...string) (string, string, error) { - t.Helper() - return runTestCommandWithInput(t, client, cwd, "", false, args...) -} - -func runTestCommandWithInput(t *testing.T, client *http.Client, cwd string, stdin string, interactive bool, args ...string) (string, string, error) { - t.Helper() - var stdout bytes.Buffer - var stderr bytes.Buffer - app := NewApp("test", strings.NewReader(stdin), &stdout, &stderr) - if client != nil { - app.HTTPClient = client + if !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()) } - app.GetCWD = func() (string, error) { return cwd, nil } - app.IsInteractive = func() bool { return interactive } - cmd := app.rootCommand() - cmd.SetArgs(args) - cmd.SetIn(app.In) - cmd.SetOut(app.Out) - cmd.SetErr(app.Err) - err := cmd.Execute() - return stdout.String(), stderr.String(), err } -func runTestAppExecute(t *testing.T, client *http.Client, cwd string, args ...string) (string, string, error) { +func testApp(t *testing.T, cwd string, client *http.Client) (*App, *bytes.Buffer) { t.Helper() - var stdout bytes.Buffer - var stderr bytes.Buffer - app := NewApp("test", strings.NewReader(""), &stdout, &stderr) - app.Args = args + var out bytes.Buffer + a := NewApp("test", strings.NewReader(""), &out, &bytes.Buffer{}) if client != nil { - app.HTTPClient = client + a.HTTPClient = client } - app.GetCWD = func() (string, error) { return cwd, nil } - err := app.Execute() - return stdout.String(), stderr.String(), err + a.GetCWD = func() (string, error) { return cwd, nil } + a.IsInteractive = func() bool { return false } + return a, &out } -func writeTestManifest(t *testing.T, dir string) { - t.Helper() - raw := `apiVersion: v1 -project: app -environment: production -service: - name: web - source: - type: image - image: nginx:1.27 - replicas: - count: 1 -` - if err := os.WriteFile(filepath.Join(dir, "techulus.yml"), []byte(raw), 0o644); err != nil { - t.Fatalf("write manifest: %v", err) - } +func execute(a *App, args ...string) error { + c := a.rootCommand() + c.SetArgs(args) + c.SetIn(a.In) + c.SetOut(a.Out) + c.SetErr(a.Err) + return c.Execute() } -func testManifest() manifest.Manifest { - return manifest.Manifest{ - APIVersion: "v1", - Project: "app", - Environment: "production", - Service: manifest.Service{ - Name: "web", - Source: manifest.Source{Type: "image", Image: "nginx:1.27"}, - Replicas: manifest.Replicas{Count: 1}, - Ports: []manifest.Port{}, - }, - } +func configHome(t *testing.T) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) } - -func writeTestConfig(t *testing.T, host string) { +func writeConfig(t *testing.T, host string) { t.Helper() - configRoot := filepath.Join(t.TempDir(), "config") - t.Setenv("XDG_CONFIG_HOME", configRoot) if err := auth.WriteConfig(auth.Config{Host: host, APIKey: "secret"}); err != nil { - t.Fatalf("WriteConfig() error = %v", err) + t.Fatal(err) } } - -func agentFlagsContain(flags []agentFlag, name string) bool { - for _, flag := range flags { - if flag.Name == name { - return true - } +func writeManifest(t *testing.T, dir, raw string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "techulus.yml"), []byte(raw), 0644); err != nil { + t.Fatal(err) } - return false } +func targetManifest() manifest.Manifest { m, _ := manifest.Parse([]byte(imageManifest)); return m } + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/cli/internal/cli/types.go b/cli/internal/cli/types.go index c92fcfe0..59359330 100644 --- a/cli/internal/cli/types.go +++ b/cli/internal/cli/types.go @@ -1,8 +1,6 @@ package cli import ( - "fmt" - "techulus/cloud-cli/internal/auth" "techulus/cloud-cli/internal/manifest" ) @@ -15,209 +13,82 @@ type deviceCodeResponse struct { ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` } - type deviceTokenResponse struct { AccessToken string `json:"access_token"` Error string `json:"error"` ErrorDescription string `json:"error_description"` } - type exchangeResponse struct { - APIKey string `json:"apiKey"` - KeyID string `json:"keyId"` - Name string `json:"name"` - User auth.User `json:"user"` + APIKey string `json:"apiKey"` + KeyID string `json:"keyId"` + Name string `json:"name"` } - type authWhoamiOutput struct { User auth.User `json:"user"` Host string `json:"host"` } - type initOutput struct { Manifest string `json:"manifest"` Next string `json:"next"` } - -type linkServiceTarget struct { - ID string `json:"id"` - Name string `json:"name"` - Project string `json:"project"` - Environment string `json:"environment"` - LinkSupported bool `json:"linkSupported"` - UnsupportedReason string `json:"unsupportedReason"` +type projectItem struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` } - -type linkEnvironmentTarget struct { - ID string `json:"id"` - Name string `json:"name"` - Services []linkServiceTarget `json:"services"` +type environmentItem struct { + ID string `json:"id"` + Name string `json:"name"` } - -type linkProjectTarget struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Environments []linkEnvironmentTarget `json:"environments"` +type serviceItem struct { + ID string `json:"id"` + Name string `json:"name"` + Hostname *string `json:"hostname"` + Source manifest.Source `json:"source"` } - -type linkTargetsResponse struct { - Projects []linkProjectTarget `json:"projects"` +type projectsResponse struct { + Projects []projectItem `json:"projects"` + NextCursor string `json:"nextCursor,omitempty"` } - -type linkManifestResponse struct { - Manifest manifest.Manifest `json:"manifest"` - Service struct { - ID string `json:"id"` - Name string `json:"name"` - Project string `json:"project"` - Environment string `json:"environment"` - } `json:"service"` +type environmentsResponse struct { + Environments []environmentItem `json:"environments"` + NextCursor string `json:"nextCursor,omitempty"` } - -type manifestChange struct { - Field string `json:"field"` - From string `json:"from"` - To string `json:"to"` +type servicesResponse struct { + Services []serviceItem `json:"services"` + NextCursor string `json:"nextCursor,omitempty"` } - type applyResponse struct { - Action string `json:"action"` - ServiceID string `json:"serviceId"` - Changes []manifestChange `json:"changes"` + Action string `json:"action"` + Changes []string `json:"changes"` } - type deployResponse struct { - ServiceID string `json:"serviceId"` - RolloutID *string `json:"rolloutId"` + Operation string `json:"operation"` Status string `json:"status"` + RolloutID *string `json:"rolloutId"` + BuildID *string `json:"buildId"` } - type statusResponse struct { Service struct { - ID string `json:"id"` - Name string `json:"name"` - Image string `json:"image"` - Hostname *string `json:"hostname"` - Replicas int `json:"replicas"` + ID string `json:"id"` + Name string `json:"name"` + Source manifest.Source `json:"source"` } `json:"service"` - LatestRollout *struct { - ID string `json:"id"` - Status string `json:"status"` - CurrentStage *string `json:"currentStage"` - } `json:"latestRollout"` - Deployments []struct { - ID string `json:"id"` - Status string `json:"status"` - ServerID string `json:"serverId"` - } `json:"deployments"` + LatestBuild map[string]any `json:"latestBuild"` + LatestRollout map[string]any `json:"latestRollout"` + Deployments []map[string]any `json:"deployments"` } - type serviceLog struct { - DeploymentID string `json:"deploymentId"` - Stream string `json:"stream"` - Message string `json:"message"` - Timestamp string `json:"timestamp"` + DeploymentID *string `json:"deploymentId"` + Stream string `json:"stream"` + Message string `json:"message"` + Timestamp string `json:"timestamp"` } type logsResponse struct { - LoggingEnabled bool `json:"loggingEnabled"` - Logs []serviceLog `json:"logs"` -} - -type serviceTargetOutput struct { - Project string `json:"project"` - Environment string `json:"environment"` - Service string `json:"service"` -} - -type statusOutput struct { - Target serviceTargetOutput `json:"target"` - Status statusResponse `json:"status"` -} - -type logsOutput struct { - Target serviceTargetOutput `json:"target"` - LoggingEnabled bool `json:"loggingEnabled"` - Logs []serviceLog `json:"logs"` -} - -func countSupportedServices(projects []linkProjectTarget) int { - total := 0 - for _, project := range projects { - for _, environment := range project.Environments { - for _, service := range environment.Services { - if service.LinkSupported { - total++ - } - } - } - } - return total -} - -func filterProjectsWithServices(projects []linkProjectTarget) []linkProjectTarget { - var filtered []linkProjectTarget - for _, project := range projects { - for _, environment := range project.Environments { - if len(environment.Services) > 0 { - filtered = append(filtered, project) - break - } - } - } - return filtered -} - -func filterEnvironmentsWithServices(environments []linkEnvironmentTarget) []linkEnvironmentTarget { - var filtered []linkEnvironmentTarget - for _, environment := range environments { - if len(environment.Services) > 0 { - filtered = append(filtered, environment) - } - } - return filtered -} - -func renderProjectChoice(project linkProjectTarget) string { - serviceCount := 0 - for _, environment := range project.Environments { - serviceCount += len(environment.Services) - } - suffix := "s" - if serviceCount == 1 { - suffix = "" - } - return fmt.Sprintf("%s (%d service%s)", project.Name, serviceCount, suffix) -} - -func renderEnvironmentChoice(environment linkEnvironmentTarget) string { - supportedCount := 0 - for _, service := range environment.Services { - if service.LinkSupported { - supportedCount++ - } - } - return fmt.Sprintf("%s (%d/%d linkable)", environment.Name, supportedCount, len(environment.Services)) -} - -func renderServiceChoice(service linkServiceTarget) string { - if service.LinkSupported { - return service.Name - } - reason := service.UnsupportedReason - if reason == "" { - reason = "unsupported" - } - return fmt.Sprintf("%s (unsupported: %s)", service.Name, reason) -} - -func disabledServiceReason(service linkServiceTarget) string { - if service.LinkSupported { - return "" - } - if service.UnsupportedReason != "" { - return service.UnsupportedReason - } - return "This service cannot be linked." + 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 7bc0cccf..8891aa94 100644 --- a/cli/internal/manifest/manifest.go +++ b/cli/internal/manifest/manifest.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "net/url" "os" "path/filepath" "regexp" @@ -12,39 +13,45 @@ import ( "gopkg.in/yaml.v3" ) +var windowsAbsolutePath = regexp.MustCompile(`^[A-Za-z]:[\\/]`) + type Manifest struct { - APIVersion string `json:"apiVersion" yaml:"apiVersion"` - Project string `json:"project" yaml:"project"` - Environment string `json:"environment" yaml:"environment"` - Service Service `json:"service" yaml:"service"` + 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"` +} +type Environment struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Name string `json:"name" yaml:"name"` } - 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,omitempty" yaml:"hostname,omitempty"` - Ports []Port `json:"ports,omitempty" yaml:"ports,omitempty"` - Replicas Replicas `json:"replicas" yaml:"replicas"` - HealthCheck *HealthCheck `json:"healthCheck,omitempty" yaml:"healthCheck,omitempty"` - StartCommand *string `json:"startCommand,omitempty" yaml:"startCommand,omitempty"` + Hostname *string `json:"hostname" yaml:"hostname"` + Ports []Port `json:"ports" yaml:"ports"` + Replicas int `json:"replicas" yaml:"replicas"` + HealthCheck *HealthCheck `json:"healthCheck" yaml:"healthCheck"` + StartCommand *string `json:"startCommand" yaml:"startCommand"` Resources *Resources `json:"resources,omitempty" yaml:"resources,omitempty"` } - type Source struct { - Type string `json:"type" yaml:"type"` - Image string `json:"image" yaml:"image"` + Type string `json:"type" yaml:"type"` + Image string `json:"image,omitempty" yaml:"image,omitempty"` + Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` + Branch string `json:"branch,omitempty" yaml:"branch,omitempty"` + RootDir *string `json:"rootDir,omitempty" yaml:"rootDir,omitempty"` } - type Port struct { - Port int `json:"port" yaml:"port"` - Public bool `json:"public" yaml:"public"` - Domain string `json:"domain,omitempty" yaml:"domain,omitempty"` -} - -type Replicas struct { - Count int `json:"count" yaml:"count"` + ContainerPort int `json:"containerPort" yaml:"containerPort"` + Public bool `json:"public" yaml:"public"` + Domain *string `json:"domain,omitempty" yaml:"domain,omitempty"` } - type HealthCheck struct { Cmd string `json:"cmd" yaml:"cmd"` Interval int `json:"interval" yaml:"interval"` @@ -52,12 +59,10 @@ type HealthCheck struct { Retries int `json:"retries" yaml:"retries"` StartPeriod int `json:"startPeriod" yaml:"startPeriod"` } - type Resources struct { - CPUCores *float64 `json:"cpuCores,omitempty" yaml:"cpuCores,omitempty"` - MemoryMB *int `json:"memoryMb,omitempty" yaml:"memoryMb,omitempty"` + CPUCores *float64 `json:"cpuCores" yaml:"cpuCores"` + MemoryMB *int `json:"memoryMb" yaml:"memoryMb"` } - type Loaded struct { Path string Manifest Manifest @@ -69,156 +74,204 @@ func Load(cwd string) (*Loaded, error) { if err != nil { return nil, err } - parsed, err := Parse(raw) + m, err := Parse(raw) if err != nil { return nil, err } - return &Loaded{Path: path, Manifest: parsed}, nil + return &Loaded{path, m}, nil } - func Parse(raw []byte) (Manifest, error) { - var parsed Manifest - decoder := yaml.NewDecoder(bytes.NewReader(raw)) - decoder.KnownFields(true) - if err := decoder.Decode(&parsed); err != nil { - return Manifest{}, err + var m Manifest + d := yaml.NewDecoder(bytes.NewReader(raw)) + d.KnownFields(true) + if err := d.Decode(&m); err != nil { + return m, err } - ApplyDefaults(&parsed) - return parsed, Validate(parsed) + ApplyDefaults(&m) + return m, Validate(m) } - -func Marshal(value Manifest) ([]byte, error) { - ApplyDefaults(&value) - if err := Validate(value); err != nil { +func Marshal(m Manifest) ([]byte, error) { + ApplyDefaults(&m) + if err := Validate(m); err != nil { return nil, err } - return yaml.Marshal(value) + return yaml.Marshal(m) } - -func Save(path string, value Manifest) error { - raw, err := Marshal(value) +func Save(path string, m Manifest) error { + raw, err := Marshal(m) if err != nil { return err } - return os.WriteFile(path, raw, 0o644) + return os.WriteFile(path, raw, 0644) } - -func ApplyDefaults(value *Manifest) { - value.Project = strings.TrimSpace(value.Project) - value.Environment = strings.TrimSpace(value.Environment) - value.Service.Name = strings.TrimSpace(value.Service.Name) - value.Service.Source.Type = strings.TrimSpace(value.Service.Source.Type) - value.Service.Source.Image = strings.TrimSpace(value.Service.Source.Image) - if value.Service.Hostname != nil { - trimmed := strings.TrimSpace(*value.Service.Hostname) - value.Service.Hostname = &trimmed +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) + m.Service.Name = strings.TrimSpace(m.Service.Name) + s := &m.Service.Source + s.Type = strings.ToLower(strings.TrimSpace(s.Type)) + s.Image = strings.TrimSpace(s.Image) + s.Repository = strings.TrimSpace(s.Repository) + s.Branch = strings.TrimSpace(s.Branch) + if s.Type == "github" && s.Repository != "" { + if v, err := CanonicalGitHubRepository(s.Repository); err == nil { + s.Repository = v + } } - if value.Service.StartCommand != nil { - trimmed := strings.TrimSpace(*value.Service.StartCommand) - value.Service.StartCommand = &trimmed + if s.RootDir != nil { + v := strings.ReplaceAll(strings.TrimSpace(*s.RootDir), "\\", "/") + s.RootDir = &v } - if value.Service.Ports == nil { - value.Service.Ports = []Port{} + if m.Service.Hostname != nil { + v := strings.TrimSpace(*m.Service.Hostname) + m.Service.Hostname = &v } - for index := range value.Service.Ports { - value.Service.Ports[index].Domain = strings.TrimSpace(value.Service.Ports[index].Domain) + if m.Service.StartCommand != nil { + v := strings.TrimSpace(*m.Service.StartCommand) + m.Service.StartCommand = &v } - if value.Service.Replicas.Count == 0 { - value.Service.Replicas.Count = 1 + if m.Service.Ports == nil { + m.Service.Ports = []Port{} } - if value.Service.HealthCheck != nil { - value.Service.HealthCheck.Cmd = strings.TrimSpace(value.Service.HealthCheck.Cmd) - if value.Service.HealthCheck.Interval == 0 { - value.Service.HealthCheck.Interval = 10 + if m.Service.Replicas == 0 { + m.Service.Replicas = 1 + } + if h := m.Service.HealthCheck; h != nil { + h.Cmd = strings.TrimSpace(h.Cmd) + if h.Interval == 0 { + h.Interval = 10 } - if value.Service.HealthCheck.Timeout == 0 { - value.Service.HealthCheck.Timeout = 5 + if h.Timeout == 0 { + h.Timeout = 5 } - if value.Service.HealthCheck.Retries == 0 { - value.Service.HealthCheck.Retries = 3 + if h.Retries == 0 { + h.Retries = 3 } - if value.Service.HealthCheck.StartPeriod == 0 { - value.Service.HealthCheck.StartPeriod = 30 + if h.StartPeriod == 0 { + h.StartPeriod = 30 } } } - -func Validate(value Manifest) error { - if value.APIVersion != "v1" { +func Validate(m Manifest) error { + if m.APIVersion != "v1" { return errors.New("apiVersion must be v1") } - if strings.TrimSpace(value.Project) == "" { - return errors.New("project is required") + if m.Project.Slug == "" { + return errors.New("project.slug is required") } - if strings.TrimSpace(value.Environment) == "" { - return errors.New("environment is required") + if m.Environment.Name == "" { + return errors.New("environment.name is required") } - if strings.TrimSpace(value.Service.Name) == "" { + if m.Service.Name == "" { return errors.New("service.name is required") } - if value.Service.Source.Type != "image" { - return errors.New("service.source.type must be image") - } - if strings.TrimSpace(value.Service.Source.Image) == "" { - return errors.New("service.source.image is required") + s := m.Service.Source + switch s.Type { + case "image": + if s.Image == "" { + return errors.New("service.source.image is required") + } + if s.Repository != "" || s.Branch != "" || s.RootDir != nil { + return errors.New("image source cannot contain GitHub fields") + } + case "github": + if s.Image != "" { + return errors.New("github source cannot contain image") + } + if _, err := CanonicalGitHubRepository(s.Repository); err != nil { + return fmt.Errorf("service.source.repository: %w", err) + } + if s.Branch == "" { + return errors.New("service.source.branch is required") + } + if s.RootDir != nil { + if *s.RootDir == "" { + return errors.New("service.source.rootDir cannot be blank") + } + if filepath.IsAbs(*s.RootDir) || strings.HasPrefix(*s.RootDir, "\\") || windowsAbsolutePath.MatchString(*s.RootDir) { + return errors.New("service.source.rootDir must be relative") + } + for _, p := range strings.FieldsFunc(*s.RootDir, func(r rune) bool { return r == '/' || r == '\\' }) { + if p == ".." { + return errors.New("service.source.rootDir cannot contain '..'") + } + } + } + default: + return errors.New("service.source.type must be image or github") } - if value.Service.Hostname != nil && *value.Service.Hostname == "" { + if m.Service.Hostname != nil && *m.Service.Hostname == "" { return errors.New("service.hostname cannot be blank") } - if value.Service.Replicas.Count < 1 || value.Service.Replicas.Count > 10 { - return errors.New("service.replicas.count must be between 1 and 10") + if m.Service.StartCommand != nil && *m.Service.StartCommand == "" { + return errors.New("service.startCommand cannot be blank") } - for index, port := range value.Service.Ports { - if port.Port < 1 || port.Port > 65535 { - return fmt.Errorf("service.ports[%d].port must be between 1 and 65535", index) - } - if port.Public && strings.TrimSpace(port.Domain) == "" { - return fmt.Errorf("service.ports[%d].domain is required for public ports", index) - } - if !port.Public && strings.TrimSpace(port.Domain) != "" { - return fmt.Errorf("service.ports[%d].domain cannot be set for internal ports", index) - } + if m.Service.Replicas < 1 || m.Service.Replicas > 10 { + return errors.New("service.replicas must be between 1 and 10") } - if health := value.Service.HealthCheck; health != nil { - if strings.TrimSpace(health.Cmd) == "" { - return errors.New("service.healthCheck.cmd is required") + seenPorts := make(map[int]struct{}, len(m.Service.Ports)) + for i, p := range m.Service.Ports { + if p.ContainerPort < 1 || p.ContainerPort > 65535 { + return fmt.Errorf("service.ports[%d].containerPort must be between 1 and 65535", i) } - if health.Interval < 1 { - return errors.New("service.healthCheck.interval must be at least 1") + if _, exists := seenPorts[p.ContainerPort]; exists { + return fmt.Errorf("service.ports[%d].containerPort must be unique", i) } - if health.Timeout < 1 { - return errors.New("service.healthCheck.timeout must be at least 1") + seenPorts[p.ContainerPort] = struct{}{} + if p.Domain != nil && strings.TrimSpace(*p.Domain) == "" { + return fmt.Errorf("service.ports[%d].domain cannot be blank", i) } - if health.Retries < 1 { - return errors.New("service.healthCheck.retries must be at least 1") + if p.Public && p.Domain == nil { + return fmt.Errorf("service.ports[%d].domain is required for public ports", i) } - if health.StartPeriod < 0 { - return errors.New("service.healthCheck.startPeriod must be at least 0") + if !p.Public && p.Domain != nil { + return fmt.Errorf("service.ports[%d].domain cannot be set for internal ports", i) } } - if value.Service.StartCommand != nil && *value.Service.StartCommand == "" { - return errors.New("service.startCommand cannot be blank") + if h := m.Service.HealthCheck; h != nil && (h.Cmd == "" || h.Interval < 1 || h.Timeout < 1 || h.Retries < 1 || h.StartPeriod < 0) { + return errors.New("service.healthCheck contains invalid values") } - if resources := value.Service.Resources; resources != nil { - hasCPU := resources.CPUCores != nil - hasMemory := resources.MemoryMB != nil - if hasCPU != hasMemory { + if r := m.Service.Resources; r != nil { + if (r.CPUCores == nil) != (r.MemoryMB == nil) { return errors.New("service.resources must set both cpuCores and memoryMb together") } - if resources.CPUCores != nil && (*resources.CPUCores < 0.1 || *resources.CPUCores > 64) { + if r.CPUCores != nil && (*r.CPUCores < 0.1 || *r.CPUCores > 64) { return errors.New("service.resources.cpuCores must be between 0.1 and 64") } - if resources.MemoryMB != nil && (*resources.MemoryMB < 64 || *resources.MemoryMB > 65536) { + if r.MemoryMB != nil && (*r.MemoryMB < 64 || *r.MemoryMB > 65536) { return errors.New("service.resources.memoryMb must be between 64 and 65536") } } return nil } +func (m Manifest) Linked() bool { + return m.Project.ID != "" && m.Environment.ID != "" && m.Service.ID != "" +} +func CanonicalGitHubRepository(value string) (string, error) { + u, err := url.Parse(strings.TrimSpace(value)) + if err != nil || u.Scheme != "https" || !strings.EqualFold(u.Hostname(), "github.com") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Port() != "" { + return "", errors.New("must be an HTTPS github.com URL without credentials, query, or fragment") + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) != 2 { + return "", errors.New("must contain owner/repo") + } + if strings.HasSuffix(strings.ToLower(parts[1]), ".git") { + parts[1] = parts[1][:len(parts[1])-4] + } + valid := regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + if parts[0] == "." || parts[0] == ".." || parts[1] == "." || parts[1] == ".." || !valid.MatchString(parts[0]) || !valid.MatchString(parts[1]) || parts[1] == "" { + return "", errors.New("invalid owner/repo path") + } + return "https://github.com/" + parts[0] + "/" + parts[1], nil +} var slugChars = regexp.MustCompile(`[^a-z0-9]+`) -func Slugify(value string) string { - slug := strings.ToLower(value) - slug = slugChars.ReplaceAllString(slug, "-") - return strings.Trim(slug, "-") +func Slugify(v string) string { + return strings.Trim(slugChars.ReplaceAllString(strings.ToLower(v), "-"), "-") } diff --git a/cli/internal/manifest/manifest_test.go b/cli/internal/manifest/manifest_test.go index 6f483662..39eaf9a3 100644 --- a/cli/internal/manifest/manifest_test.go +++ b/cli/internal/manifest/manifest_test.go @@ -5,107 +5,95 @@ import ( "testing" ) -func TestParseValidManifestAppliesDefaults(t *testing.T) { - raw := []byte(`apiVersion: v1 -project: app -environment: production -service: - name: web - source: - type: image - image: nginx:1.27 -`) - parsed, err := Parse(raw) - if err != nil { - t.Fatalf("Parse() error = %v", err) - } - if parsed.Service.Replicas.Count != 1 { - t.Fatalf("replicas default = %d, want 1", parsed.Service.Replicas.Count) +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}} +} +func TestDefaultsAndRoundTrip(t *testing.T) { + m := base() + m.Service.Replicas = 0 + b, e := Marshal(m) + if e != nil { + t.Fatal(e) } - if parsed.Service.Ports == nil { - t.Fatal("ports default should be an empty slice, not nil") + got, e := Parse(b) + if e != nil || got.Service.Replicas != 1 || got.Service.Ports == nil { + t.Fatalf("got=%#v err=%v", got, e) } } - -func TestValidatePortDomainRules(t *testing.T) { - base := validManifest() - base.Service.Ports = []Port{{Port: 80, Public: true}} - if err := Validate(base); err == nil || !strings.Contains(err.Error(), "domain is required") { - t.Fatalf("Validate(public without domain) = %v", err) +func TestGitHubCanonical(t *testing.T) { + m := base() + root := `packages\web` + m.Service.Source = Source{Type: "github", Repository: "https://github.com/acme/repo.git/", Branch: " main ", RootDir: &root} + b, e := Marshal(m) + if e != nil { + t.Fatal(e) } - - base = validManifest() - base.Service.Ports = []Port{{Port: 80, Public: false, Domain: "example.com"}} - if err := Validate(base); err == nil || !strings.Contains(err.Error(), "domain cannot be set") { - t.Fatalf("Validate(internal with domain) = %v", err) + if !strings.Contains(string(b), "https://github.com/acme/repo") { + t.Fatal(string(b)) + } + if !strings.Contains(string(b), "packages/web") { + t.Fatalf("rootDir was not normalized: %s", b) } } - -func TestValidateResourcesMustBePaired(t *testing.T) { - cpu := 1.0 - base := validManifest() - base.Service.Resources = &Resources{CPUCores: &cpu} - if err := Validate(base); err == nil || !strings.Contains(err.Error(), "both cpuCores and memoryMb") { - t.Fatalf("Validate(resources) = %v", err) +func TestRejectMixedAndRootEscape(t *testing.T) { + m := base() + m.Service.Source = Source{Type: "github", Image: "x", Repository: "https://github.com/a/b", Branch: "main"} + if Validate(m) == nil { + t.Fatal("mixed source accepted") + } + root := "../x" + m.Service.Source.Image = "" + m.Service.Source.RootDir = &root + if Validate(m) == nil { + t.Fatal("escaping root accepted") } } - -func TestValidateBlankOptionalStrings(t *testing.T) { - raw := []byte(`apiVersion: v1 -project: app -environment: production -service: - name: web - source: - type: image - image: nginx:1.27 - hostname: " " -`) - if _, err := Parse(raw); err == nil || !strings.Contains(err.Error(), "hostname cannot be blank") { - t.Fatalf("Parse(blank hostname) = %v", err) +func TestRejectGitHubURL(t *testing.T) { + for _, v := range []string{"http://github.com/a/b", "https://user@github.com/a/b", "https://gitlab.com/a/b", "https://github.com/a/b?q=1"} { + if _, e := CanonicalGitHubRepository(v); e == nil { + t.Fatalf("accepted %s", v) + } } +} - raw = []byte(`apiVersion: v1 -project: app -environment: production -service: - name: web - source: - type: image - image: nginx:1.27 - startCommand: " " -`) - if _, err := Parse(raw); err == nil || !strings.Contains(err.Error(), "startCommand cannot be blank") { - t.Fatalf("Parse(blank startCommand) = %v", err) +func TestRejectWindowsAbsoluteRootDir(t *testing.T) { + for _, root := range []string{`C:\app`, `D:/service`, `\\server\share`} { + m := base() + m.Service.Source = Source{Type: "github", Repository: "https://github.com/a/b", Branch: "main", RootDir: &root} + if err := Validate(m); err == nil || !strings.Contains(err.Error(), "must be relative") { + t.Fatalf("rootDir %q error = %v", root, err) + } } } -func TestMarshalRoundTrip(t *testing.T) { - value := validManifest() - value.Service.Ports = []Port{{Port: 443, Public: true, Domain: "app.example.com"}} - raw, err := Marshal(value) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - parsed, err := Parse(raw) - if err != nil { - t.Fatalf("Parse(Marshal()) error = %v", err) - } - if parsed.Service.Ports[0].Domain != "app.example.com" { - t.Fatalf("domain = %q", parsed.Service.Ports[0].Domain) +func TestRejectDuplicatePorts(t *testing.T) { + m := base() + m.Service.Ports = []Port{{ContainerPort: 8080}, {ContainerPort: 8080}} + if err := Validate(m); err == nil || !strings.Contains(err.Error(), "must be unique") { + t.Fatalf("error = %v", err) } } -func validManifest() Manifest { - return Manifest{ - APIVersion: "v1", - Project: "app", - Environment: "production", - Service: Service{ - Name: "web", - Source: Source{Type: "image", Image: "nginx:1.27"}, - Replicas: Replicas{Count: 1}, - Ports: []Port{}, - }, +func TestPublicAndInternalPortDomainRules(t *testing.T) { + domain := "app.example.com" + for _, tc := range []struct { + name string + port Port + }{ + {"public requires domain", Port{ContainerPort: 80, Public: true}}, + {"internal rejects domain", Port{ContainerPort: 80, Domain: &domain}}, + } { + t.Run(tc.name, func(t *testing.T) { + m := base() + m.Service.Ports = []Port{tc.port} + if err := Validate(m); err == nil { + t.Fatal("invalid external-port configuration accepted") + } + }) + } + m := base() + m.Service.Ports = []Port{{ContainerPort: 80, Public: true, Domain: &domain}, {ContainerPort: 5432}} + if err := Validate(m); err != nil { + t.Fatalf("valid public/internal ports rejected: %v", err) } } diff --git a/cli/justfile b/cli/justfile new file mode 100644 index 00000000..dc73cd34 --- /dev/null +++ b/cli/justfile @@ -0,0 +1,46 @@ +binary := "dist/tc" +version := env_var_or_default("VERSION", "dev") + +# List available tasks. +default: + @just --list + +# Build the CLI binary at dist/tc. Override the version with VERSION=. +build: + mkdir -p dist + go build -trimpath -ldflags "-X main.version={{version}}" -o {{binary}} ./cmd/tc + +# Run the CLI without building a binary first, forwarding any arguments. +run *args: + go run -trimpath -ldflags "-X main.version={{version}}" ./cmd/tc {{args}} + +# Run all tests. +test: + go test ./... + +# Run Go's static analyzer. +vet: + go vet ./... + +# Format all Go packages. +fmt: + go fmt ./... + +# Fail when any Go source file is not gofmt-formatted. +fmt-check: + @unformatted="$(gofmt -l $(find . -type f -name '*.go'))"; \ + if [ -n "$unformatted" ]; then \ + printf 'These files need gofmt:\n%s\n' "$unformatted"; \ + exit 1; \ + fi + +# Run the non-mutating local checks. +check: fmt-check test vet + +# Install tc into GOBIN. +install: + go install -trimpath -ldflags "-X main.version={{version}}" ./cmd/tc + +# Remove generated binaries. +clean: + rm -rf dist diff --git a/docs/api/public-api.mdx b/docs/api/public-api.mdx new file mode 100644 index 00000000..282dac7c --- /dev/null +++ b/docs/api/public-api.mdx @@ -0,0 +1,292 @@ +--- +title: "Public API reference" +description: "Automate service configuration, deployments, and observability." +--- + +The public API is available under `/api/v1`. The `tc` CLI uses this API directly. + +## Authentication + +Send your CLI API key in the `X-API-Key` header on every resource request: + +```bash +curl https://cloud.example.com/api/v1/me \ + --header "X-API-Key: $TECHULUS_API_KEY" +``` + +Browser cookies and bearer tokens cannot access resource endpoints. A bearer token or browser session can only create an API key with `POST /api/v1/api-keys`. An API key cannot create another API key. + +`tc auth login` completes the device flow, creates an API key, and stores it locally. `tc auth logout` only removes the local credentials. + +### Create an API key + +```http +POST /api/v1/api-keys +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "name": "CLI workstation", + "metadata": { + "machineName": "workstation", + "platform": "darwin/arm64" + } +} +``` + +`name` is required and can contain up to 32 characters. The response contains the secret once: + +```json +{ + "apiKey": "tcl_...", + "keyId": "...", + "name": "CLI workstation" +} +``` + +## Authorization and containment + +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: + +```text +/api/v1/projects/{projectId}/environments/{environmentId}/services/{serviceId} +``` + +The API returns `404 NOT_FOUND` when an ID exists but does not belong to the parent IDs in the URL. + +## Errors + +Every API error uses the same shape: + +```json +{ + "message": "Resource not found", + "code": "NOT_FOUND" +} +``` + +Use `code` for automation. Do not match the human-readable `message`. + +## Identity and collections + +| Method | Path | Description | +| --- | --- | --- | +| `GET` | `/api/v1/me` | Return the API-key user, role, and key-backed session | +| `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 | + +Collection responses use an opaque keyset cursor: + +```json +{ + "services": [ + { + "id": "...", + "name": "web", + "hostname": "web", + "source": { "type": "image", "image": "nginx:1.27" }, + "createdAt": "2026-07-20T00:00:00.000Z" + } + ], + "nextCursor": null +} +``` + +Pass `nextCursor` as `?cursor=...`. `limit` defaults to 100 and accepts values from 1 through 100. + +## Service resources + +The paths in this table are relative to the nested service URL. + +| Method | Path suffix | Description | +| --- | --- | --- | +| `GET`, `PATCH` | `/configuration` | Read safe current/active configuration or atomically apply the managed subset | +| `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 | +| `GET` | `/rollouts` | List rollouts with cursor pagination | +| `GET` | `/rollouts/{rolloutId}` | Read a contained rollout and its deployments | +| `GET` | `/rollouts/{rolloutId}/logs` | Search bounded rollout logs | +| `GET` | `/builds` | List GitHub builds with cursor pagination | +| `GET` | `/metrics` | Read metrics with explicit provider state | +| `GET` | `/revisions` | Read a redacted configuration changelog | + +Rollout and build collections accept `limit` from 1 through 100 and an opaque `cursor`. Their default limit is 25. Revisions accept their returned opaque `cursor` and return up to 25 items. + +## Configuration + +`GET /configuration` returns these distinct states: + +- `current` is the mutable configuration stored for the service. +- `active` is the immutable specification used by the active deployment. +- `activeRevisionId` and `activeDeploymentId` identify that active state. +- `hasPendingChanges` and `changes` compare current and active state. +- `management.patchable` and `management.blockers` explain whether the API can safely manage the service. + +A GitHub service keeps mutable repository settings in `current.source`. Its `active.source` comes from the immutable service revision used by the active deployment. The public projection includes the snapshotted repository, branch, and root directory. It omits repository IDs and authentication details. + +Configuration and revision responses never include secret names, values, or ciphertext. + +### Patch configuration + +`PATCH /configuration` is atomic. Every field is optional. Omitted fields remain unchanged. An included `ports` array replaces the entire managed port set. + +```json +{ + "source": { + "type": "github", + "repository": "https://github.com/techulus/cloud", + "branch": "main", + "rootDir": "web" + }, + "hostname": "cloud", + "ports": [ + { + "containerPort": 3000, + "public": true, + "domain": "cloud.example.com" + } + ], + "replicas": 1, + "healthCheck": { + "cmd": "curl --fail http://localhost:3000/health", + "interval": 10, + "timeout": 5, + "retries": 3, + "startPeriod": 30 + }, + "startCommand": null, + "resources": { + "cpuCores": 1, + "memoryMb": 512 + } +} +``` + +The API supports these source variants: + +```json +{ "type": "image", "image": "nginx:1.27" } +``` + +```json +{ + "type": "github", + "repository": "https://github.com/owner/repository", + "branch": "main", + "rootDir": "services/api" +} +``` + +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. + +The API only manages stateless services with HTTP ports. Existing volumes, stateful mode, TCP or UDP ports, TLS passthrough, unsupported placement, or invalid resource limits return a conflict with an actionable code. `replicas` must match the existing server placement. + +## Deployments and builds + +`POST /deploy` uses the persisted service source. It does not apply configuration first. + +- An image service queues a rollout. +- A GitHub service queues a build. A rollout starts after a successful build. + +Deploy responses are always HTTP 202: + +```json +{ + "operation": "rollout", + "status": "queued", + "rolloutId": "...", + "buildId": null +} +``` + +Image migration can return `status: "migration_started"` and `rolloutId: null`. GitHub returns: + +```json +{ + "operation": "build", + "status": "build_queued", + "rolloutId": null, + "buildId": null +} +``` + +Build records are created asynchronously, so `buildId` is initially `null`. `/status` reports `latestBuild` and `latestRollout` independently. + +Each GitHub deployment resolves an exact 40-character commit before it queues build work. The control plane creates an immutable service revision that snapshots the service configuration, source provenance, and reserved artifact identity. Every platform build references that revision and reads its repository, branch, commit, root directory, authentication mode, secrets, and final image URI from the revision. + +After all platform images succeed, the control plane creates the final manifest and rolls out that same revision. Configuration changes made while a build is running do not alter its inputs. Retrying a failed or cancelled build creates a new revision with a new artifact identity. It never overwrites an artifact reserved by an earlier revision. + +## Logs + +Service logs accept these query parameters: + +| Parameter | Description | +| --- | --- | +| `q` | Literal, case-insensitive message search; maximum 200 characters | +| `range` | `1h`, `6h`, `24h`, or `7d`; defaults to `24h` | +| `limit` or `tail` | 1 through 1,000 records; defaults to 100 | +| `cursor` | Continue after the last emitted record by using the opaque `nextCursor` value | +| `wait` | Long-poll for 0 through 20 seconds when `cursor` is present | + +Round-trip `nextCursor` without decoding or modifying it. When `hasMore` is `true`, request the next page immediately. Otherwise, respect `pollAfterMs` before the next request. Keep the service, `q`, and `range` unchanged while reusing a cursor. + +```json +{ + "provider": "enabled", + "logs": [ + { + "deploymentId": "...", + "stream": "stdout", + "message": "server started", + "timestamp": "2026-07-20T00:00:00.123456789Z" + } + ], + "nextCursor": "eyJ2IjoxLCJ0IjoiLi4uIiwiZSI6Ii4uLiJ9", + "hasMore": false, + "pollAfterMs": 250 +} +``` + +When logging is not configured, the response uses `provider: "disabled"` with an empty list. An upstream failure returns `502 LOG_PROVIDER_ERROR`. Log following returns `409 LOG_CURSOR_UNAVAILABLE` if an agent must be upgraded before it can provide deterministic cursors. + +Rollout logs accept `q` and `limit`. Their response includes bounded stage messages for the contained rollout. + +## Metrics + +Metrics accept `range=1h|6h|24h|7d|30d`. + +- A configured provider returns `{ "provider": "enabled", "metrics": ... }`. `metrics` can contain successful empty series. +- A disabled provider returns `{ "provider": "disabled", "metrics": null }`. +- An upstream failure returns `502 METRICS_PROVIDER_ERROR`. + +The endpoint exposes fixed service metrics. It does not accept raw PromQL. + +## CLI commands + +The CLI uses the same endpoints documented above: + +| Command | Purpose | +| --- | --- | +| `tc config` | Show current and active service configuration | +| `tc environments` | List project environments | +| `tc services` | List environment services | +| `tc status` | Show build, rollout, and deployment status | +| `tc logs -q ` | Search or follow service logs | +| `tc rollouts` | List rollout history | +| `tc rollout ` | Show rollout detail | +| `tc rollout logs ` | Fetch rollout logs | +| `tc builds` | List builds when the source supports them | +| `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. diff --git a/docs/docs.json b/docs/docs.json index d08cb6cd..53f35d83 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -67,6 +67,12 @@ "infrastructure/backups", "infrastructure/alerts" ] + }, + { + "group": "API", + "pages": [ + "api/public-api" + ] } ] } diff --git a/web/actions/builds.ts b/web/actions/builds.ts index 36d369ed..b52dd868 100644 --- a/web/actions/builds.ts +++ b/web/actions/builds.ts @@ -1,12 +1,17 @@ "use server"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { db } from "@/db"; import { builds, githubRepos, services } from "@/db/schema"; import { requireDeveloperRole } from "@/lib/auth"; import { isFullCommitSha, listGitHubCommits } from "@/lib/github"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; +import { + requeueBuildRevisionInternal, + triggerBuildInternal, + triggerResolvedBuildInternal, +} from "@/lib/trigger-build"; export async function cancelBuild(buildId: string) { await requireDeveloperRole(); @@ -27,16 +32,43 @@ export async function cancelBuild(buildId: string) { throw new Error(`Cannot cancel build in ${build.status} status`); } - await db + const cancelled = await db .update(builds) .set({ status: "cancelled", completedAt: new Date() }) - .where(eq(builds.id, buildId)); + .where( + and( + eq(builds.id, buildId), + inArray(builds.status, [ + "pending", + "claimed", + "cloning", + "building", + "pushing", + ]), + ), + ) + .returning({ id: builds.id }) + .then((rows) => rows[0]); + if (!cancelled) { + const current = await db + .select({ status: builds.status }) + .from(builds) + .where(eq(builds.id, buildId)) + .then((rows) => rows[0]); + if (!current) throw new Error("Build not found"); + throw new Error(`Cannot cancel build in ${current.status} status`); + } await inngest.send( - inngestEvents.buildCancelled.create({ - buildId, - buildGroupId: build.buildGroupId, - }), + inngestEvents.buildCancelled.create( + { + buildId, + buildGroupId: build.buildGroupId, + }, + { + id: `build-cancelled-${buildId}`, + }, + ), ); return { success: true }; @@ -64,18 +96,17 @@ export async function retryBuild(buildId: string) { throw new Error(`Cannot retry build in ${build.status} status`); } - await inngest.send( - inngestEvents.buildTrigger.create({ - serviceId: build.serviceId, - trigger: "manual", - githubRepoId: build.githubRepoId ?? undefined, - commitSha: build.commitSha, - commitMessage: build.commitMessage ?? "Retry build", - branch: build.branch, - author: build.author ?? undefined, - actor: { type: "user", userId: session.user.id, name: session.user.name }, - }), - ); + await requeueBuildRevisionInternal({ + serviceId: build.serviceId, + serviceRevisionId: build.serviceRevisionId, + commitMessage: build.commitMessage ?? "Retry build", + author: build.author ?? undefined, + actor: { + type: "user", + userId: session.user.id, + name: session.user.name, + }, + }); return { success: true }; } @@ -92,65 +123,13 @@ export async function triggerBuild( name: session.user.name, } : ({ type: "system" } as const); - const [service] = await db - .select() - .from(services) - .where(and(eq(services.id, serviceId), isNull(services.deletedAt))); - - if (!service) { - throw new Error("Service not found"); - } - - if (service.sourceType !== "github") { - throw new Error("Service is not connected to GitHub"); - } - - const triggerMessage = - trigger === "scheduled" - ? "Scheduled build trigger" - : "Manual build trigger"; - - const [githubRepo] = await db - .select() - .from(githubRepos) - .where(eq(githubRepos.serviceId, serviceId)); - - if (githubRepo) { - await inngest.send( - inngestEvents.buildTrigger.create({ - serviceId, - trigger, - githubRepoId: githubRepo.id, - commitSha: "HEAD", - commitMessage: triggerMessage, - branch: githubRepo.deployBranch || githubRepo.defaultBranch || "main", - actor, - }), - ); - - return { success: true }; - } - - if (!service.githubRepoUrl) { - throw new Error("No GitHub repository linked to this service"); - } - - await inngest.send( - inngestEvents.buildTrigger.create({ - serviceId, - trigger, - commitSha: "HEAD", - commitMessage: triggerMessage, - branch: service.githubBranch || "main", - actor, - }), - ); - + await triggerBuildInternal(serviceId, trigger, actor); return { success: true }; } export async function triggerManualBuild(serviceId: string, commitSha: string) { - await requireDeveloperRole(); + const session = await requireDeveloperRole(); + if (!session) throw new Error("Unauthorized"); if (!isFullCommitSha(commitSha)) { throw new Error("Commit SHA must be a full 40-character hexadecimal SHA"); } @@ -182,17 +161,19 @@ export async function triggerManualBuild(serviceId: string, commitSha: string) { ); } - await inngest.send( - inngestEvents.buildTrigger.create({ - serviceId, - trigger: "manual", - githubRepoId: result.githubRepo.id, - commitSha: commit.sha.toLowerCase(), - commitMessage: commit.message.substring(0, 500), - branch, - author: commit.author ?? undefined, - }), - ); + await triggerResolvedBuildInternal(serviceId, { + trigger: "manual", + commitSha: commit.sha, + commitMessage: commit.message, + author: commit.author ?? undefined, + expectedRepository: `https://github.com/${result.githubRepo.repoFullName}`, + expectedBranch: branch, + actor: { + type: "user", + userId: session.user.id, + name: session.user.name, + }, + }); return { success: true }; } diff --git a/web/actions/projects.ts b/web/actions/projects.ts index b2f2e9a7..2f3ce3da 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -35,6 +35,7 @@ import { markDeploymentRemoved, runtimeExpectedStates, } from "@/lib/deployment-status"; +import { validateDockerImageInternal } from "@/lib/docker-image"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; import { restoreDrainingDeploymentsForRollback } from "@/lib/inngest/functions/rollout-utils"; @@ -56,180 +57,11 @@ import { getZodErrorMessage, slugify } from "@/lib/utils"; import { enqueueWork } from "@/lib/work-queue"; import { deleteBackup } from "./backups"; -function isValidImageReferencePart(reference: string): boolean { - const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; - const digestPattern = /^[A-Za-z0-9_+.-]+:[0-9a-fA-F]{32,256}$/; - - return ( - reference === "latest" || - tagPattern.test(reference) || - digestPattern.test(reference) - ); -} - -function isValidImageNamePart(part: string): boolean { - const segmentPattern = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; - return part.split("/").every((segment) => segmentPattern.test(segment)); -} - -function parseImageReference(image: string): { - registry: string; - namespace: string; - repository: string; - tag: string | null; - digest: string | null; -} { - let registry = "docker.io"; - let namespace = "library"; - let repository: string; - let tag: string | null = "latest"; - let digest: string | null = null; - let imagePath = image; - - const digestIndex = imagePath.indexOf("@"); - if (digestIndex !== -1) { - digest = imagePath.substring(digestIndex + 1); - imagePath = imagePath.substring(0, digestIndex); - tag = null; - } else { - const tagIndex = imagePath.lastIndexOf(":"); - if (tagIndex !== -1 && !imagePath.substring(tagIndex).includes("/")) { - tag = imagePath.substring(tagIndex + 1); - imagePath = imagePath.substring(0, tagIndex); - } - } - - const parts = imagePath.split("/"); - - if (parts.length === 1) { - repository = parts[0]; - } else if (parts.length === 2) { - if (parts[0].includes(".") || parts[0].includes(":")) { - registry = parts[0]; - repository = parts[1]; - } else { - namespace = parts[0]; - repository = parts[1]; - } - } else { - registry = parts[0]; - namespace = parts.slice(1, -1).join("/"); - repository = parts[parts.length - 1]; - } - - return { registry, namespace, repository, tag, digest }; -} - export async function validateDockerImage( image: string, ): Promise<{ valid: boolean; error?: string }> { await requireDeveloperRole(); - try { - const { registry, namespace, repository, tag, digest } = - parseImageReference(image); - const reference = digest || tag || "latest"; - - if (!isValidImageReferencePart(reference)) { - return { - valid: false, - error: "Invalid image tag or digest", - }; - } - - if (!isValidImageNamePart(namespace) || !isValidImageNamePart(repository)) { - return { - valid: false, - error: "Invalid image name", - }; - } - - if (registry === "docker.io") { - const repoPath = - namespace === "library" ? repository : `${namespace}/${repository}`; - - if (digest) { - const tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${namespace === "library" ? "library/" : ""}${repoPath}:pull`; - const tokenResponse = await fetch(tokenUrl); - if (!tokenResponse.ok) { - return { - valid: false, - error: "Failed to authenticate with Docker Hub", - }; - } - const tokenData = await tokenResponse.json(); - const token = tokenData.token; - - const manifestUrl = `https://registry-1.docker.io/v2/${namespace === "library" ? "library/" : ""}${repoPath}/manifests/${digest}`; - const manifestResponse = await fetch(manifestUrl, { - headers: { - Authorization: `Bearer ${token}`, - Accept: - "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json", - }, - }); - - if (manifestResponse.status === 404) { - return { - valid: false, - error: "Image digest not found on Docker Hub", - }; - } - if (!manifestResponse.ok) { - return { valid: false, error: "Failed to validate image" }; - } - return { valid: true }; - } - - const url = `https://hub.docker.com/v2/repositories/${namespace === "library" ? "library/" : ""}${repoPath}/tags/${reference}`; - const response = await fetch(url, { method: "GET" }); - - if (response.status === 404) { - return { valid: false, error: "Image or tag not found on Docker Hub" }; - } - if (!response.ok) { - return { valid: false, error: "Failed to validate image" }; - } - return { valid: true }; - } - - if (registry === "ghcr.io") { - const tokenUrl = `https://ghcr.io/token?scope=repository:${namespace}/${repository}:pull`; - const tokenResponse = await fetch(tokenUrl); - if (!tokenResponse.ok) { - return { - valid: false, - error: "Image not found on GitHub Container Registry", - }; - } - const tokenData = await tokenResponse.json(); - const token = tokenData.token; - - const manifestUrl = `https://ghcr.io/v2/${namespace}/${repository}/manifests/${reference}`; - const manifestResponse = await fetch(manifestUrl, { - headers: { - Authorization: `Bearer ${token}`, - Accept: - "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json", - }, - }); - - if (manifestResponse.status === 404) { - return { - valid: false, - error: `Image ${digest ? "digest" : "tag"} not found on GitHub Container Registry`, - }; - } - if (!manifestResponse.ok) { - return { valid: false, error: "Failed to validate image" }; - } - return { valid: true }; - } - - return { valid: true }; - } catch (error) { - console.error("Image validation error:", error); - return { valid: false, error: "Failed to validate image" }; - } + return validateDockerImageInternal(image); } export async function createProject(name: string) { @@ -924,11 +756,12 @@ export async function updateServiceGithubRepo( export async function deployService(serviceId: string) { const session = await requireDeveloperRole(); if (!session) throw new Error("Unauthorized"); - return deployServiceInternal(serviceId, { + const actor = { type: "user", userId: session.user.id, name: session.user.name, - }); + } as const; + return deployServiceInternal(serviceId, actor, { githubTrigger: "manual" }); } export async function deleteDeployments(serviceId: string) { diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx index 7cf50eee..8a816bc3 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/builds/[buildId]/page.tsx @@ -3,7 +3,8 @@ import { notFound } from "next/navigation"; import { BuildDetails } from "@/components/builds/build-details"; import { SetBreadcrumbs } from "@/components/core/breadcrumb-data"; import { db } from "@/db"; -import { builds, githubRepos, projects, services } from "@/db/schema"; +import { builds, projects, serviceRevisions, services } from "@/db/schema"; +import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; async function getBuild( projectSlug: string, @@ -34,13 +35,28 @@ async function getBuild( if (!build) return null; - let githubRepo = null; - if (build.githubRepoId) { - githubRepo = await db - .select() - .from(githubRepos) - .where(eq(githubRepos.id, build.githubRepoId)) - .then((r) => r[0]); + const revision = await db + .select({ specification: serviceRevisions.specification }) + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.id, build.serviceRevisionId), + eq(serviceRevisions.serviceId, build.serviceId), + ), + ) + .then((rows) => rows[0]); + let githubRepo: { repoFullName: string } | null = null; + try { + const source = revision + ? parseServiceRevisionSpec(revision.specification).source + : null; + if (source?.type === "github") { + githubRepo = { + repoFullName: new URL(source.repository).pathname.replace(/^\//, ""), + }; + } + } catch { + // Keep historical builds visible when their revision cannot be parsed. } return { diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts index 15949823..5988f756 100644 --- a/web/app/api/projects/[id]/services/route.ts +++ b/web/app/api/projects/[id]/services/route.ts @@ -20,7 +20,11 @@ import { } from "@/db/schema"; import { auth } from "@/lib/auth"; import { getTimestamp } from "@/lib/date"; -import { revisionSpecToDeployedConfig } from "@/lib/service-config"; +import { resolvePersistedSourceFromRows } from "@/lib/public-api"; +import { + revisionSpecToDeployedConfig, + type SourceConfig, +} from "@/lib/service-config"; export async function GET( request: Request, @@ -118,12 +122,25 @@ export async function GET( : Promise.resolve(null), service.sourceType === "github" ? db - .select({ id: githubRepos.id }) + .select() .from(githubRepos) .where(eq(githubRepos.serviceId, service.id)) .then((r) => r[0] || null) : Promise.resolve(null), ]); + const persistedSource = resolvePersistedSourceFromRows( + service, + githubRepo ?? undefined, + ); + const currentSource: SourceConfig = + persistedSource.type === "github" + ? { + type: "github", + repository: persistedSource.repository, + branch: persistedSource.branch, + rootDir: persistedSource.rootDir ?? null, + } + : persistedSource; const activeDeployment = serviceDeployments.find( (deployment) => @@ -248,6 +265,7 @@ export async function GET( latestBuild, hasGithubAppRepo: githubRepo !== null, activeConfig, + currentSource, deletionBackupFallback, }; }), diff --git a/web/app/api/services/[id]/revisions/route.ts b/web/app/api/services/[id]/revisions/route.ts index c7fb9985..c11e7b08 100644 --- a/web/app/api/services/[id]/revisions/route.ts +++ b/web/app/api/services/[id]/revisions/route.ts @@ -1,55 +1,11 @@ export const dynamic = "force-dynamic"; -import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { db } from "@/db"; -import { rollouts, servers, serviceRevisions, services } from "@/db/schema"; +import { services } from "@/db/schema"; import { requireRequestSession } from "@/lib/api-auth"; -import { sanitizeServiceRevisionActor } from "@/lib/service-revision-actor"; -import { - diffServiceRevisionSpecs, - parseServiceRevisionSpec, - type ServiceRevisionChangelogItem, - type ServiceRevisionChangelogResponse, -} from "@/lib/service-revision-changes"; -import type { ServiceRevisionSpec } from "@/lib/service-revision-spec"; - -const PAGE_SIZE = 25; -const REVISION_CURSOR_TIMESTAMP = - /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}(?::?\d{2})?)$/; - -type RevisionCursor = { - createdAt: string; - id: string; -}; - -function encodeCursor(cursor: RevisionCursor): string { - return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); -} - -function decodeCursor(value: string): RevisionCursor | null { - try { - const decoded = JSON.parse( - Buffer.from(value, "base64url").toString("utf8"), - ) as unknown; - if (!decoded || typeof decoded !== "object") return null; - - const cursor = decoded as Partial; - if ( - typeof cursor.createdAt !== "string" || - !REVISION_CURSOR_TIMESTAMP.test(cursor.createdAt) || - Number.isNaN(Date.parse(cursor.createdAt)) || - typeof cursor.id !== "string" || - cursor.id.length === 0 || - cursor.id.length > 200 - ) { - return null; - } - - return { createdAt: cursor.createdAt, id: cursor.id }; - } catch { - return null; - } -} +import { decodeTimestampCursor } from "@/lib/public-api-pagination"; +import { queryServiceRevisionChangelog } from "@/lib/service-revision-changelog"; export async function GET( request: Request, @@ -60,7 +16,7 @@ export async function GET( const { id: serviceId } = await params; const cursorValue = new URL(request.url).searchParams.get("cursor"); - const cursor = cursorValue ? decodeCursor(cursorValue) : null; + const cursor = decodeTimestampCursor(cursorValue); if (cursorValue && !cursor) { return Response.json( { message: "Invalid revision cursor" }, @@ -77,143 +33,7 @@ export async function GET( return Response.json({ message: "Service not found" }, { status: 404 }); } - const revisions = await db - .select({ - id: serviceRevisions.id, - createdAt: serviceRevisions.createdAt, - cursorCreatedAt: sql`${serviceRevisions.createdAt}::text`, - specification: serviceRevisions.specification, - actor: serviceRevisions.actor, - }) - .from(serviceRevisions) - .where( - and( - eq(serviceRevisions.serviceId, serviceId), - cursor - ? or( - lt( - serviceRevisions.createdAt, - sql`${cursor.createdAt}::timestamptz`, - ), - and( - eq( - serviceRevisions.createdAt, - sql`${cursor.createdAt}::timestamptz`, - ), - lt(serviceRevisions.id, cursor.id), - ), - ) - : undefined, - ), - ) - .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id)) - .limit(PAGE_SIZE + 1); - - const pageRevisions = revisions.slice(0, PAGE_SIZE); - const revisionIds = pageRevisions.map((revision) => revision.id); - const parsedSpecifications = new Map(); - for (const revision of revisions) { - try { - parsedSpecifications.set( - revision.id, - parseServiceRevisionSpec(revision.specification), - ); - } catch { - // Unsupported or malformed historical revisions remain visible. - } - } - const placementServerIds = [ - ...new Set( - [...parsedSpecifications.values()].flatMap((specification) => - specification.placements.map((placement) => placement.serverId), - ), - ), - ]; - const [serverRows, revisionRollouts] = await Promise.all([ - placementServerIds.length > 0 - ? db - .select({ id: servers.id, name: servers.name }) - .from(servers) - .where(inArray(servers.id, placementServerIds)) - : Promise.resolve([]), - revisionIds.length > 0 - ? db - .select({ - id: rollouts.id, - serviceRevisionId: rollouts.serviceRevisionId, - status: rollouts.status, - createdAt: rollouts.createdAt, - }) - .from(rollouts) - .where( - and( - eq(rollouts.serviceId, serviceId), - inArray(rollouts.serviceRevisionId, revisionIds), - ), - ) - .orderBy(desc(rollouts.createdAt), desc(rollouts.id)) - : Promise.resolve([]), - ]); - const serverNames = new Map( - serverRows.map((server) => [server.id, server.name] as const), - ); - const rolloutByRevisionId = new Map< - string, - (typeof revisionRollouts)[number] - >(); - for (const rollout of revisionRollouts) { - if ( - rollout.serviceRevisionId && - !rolloutByRevisionId.has(rollout.serviceRevisionId) - ) { - rolloutByRevisionId.set(rollout.serviceRevisionId, rollout); - } - } - - const items: ServiceRevisionChangelogItem[] = pageRevisions.map( - (revision, index) => { - const previous = revisions[index + 1]; - let comparison: ServiceRevisionChangelogItem["comparison"]; - if (!previous) { - comparison = { kind: "initial" }; - } else { - const currentSpecification = parsedSpecifications.get(revision.id); - const previousSpecification = parsedSpecifications.get(previous.id); - if (currentSpecification && previousSpecification) { - comparison = { - kind: "changes", - changes: diffServiceRevisionSpecs( - previousSpecification, - currentSpecification, - serverNames, - ), - }; - } else { - comparison = { kind: "unavailable" }; - } - } - - const rollout = rolloutByRevisionId.get(revision.id); - return { - id: revision.id, - createdAt: revision.createdAt.toISOString(), - actor: sanitizeServiceRevisionActor(revision.actor), - comparison, - rollout: rollout ? { id: rollout.id, status: rollout.status } : null, - }; - }, + return Response.json( + await queryServiceRevisionChangelog(serviceId, cursor ?? undefined), ); - - const response: ServiceRevisionChangelogResponse = { - revisions: items, - nextCursor: - revisions.length > PAGE_SIZE && pageRevisions.length > 0 - ? encodeCursor({ - createdAt: pageRevisions[pageRevisions.length - 1].cursorCreatedAt, - id: pageRevisions[pageRevisions.length - 1].id, - }) - : null, - }; - - return Response.json(response); } diff --git a/web/app/api/v1/agent/builds/[id]/route.ts b/web/app/api/v1/agent/builds/[id]/route.ts index be1d4d1e..b0ddfc97 100644 --- a/web/app/api/v1/agent/builds/[id]/route.ts +++ b/web/app/api/v1/agent/builds/[id]/route.ts @@ -1,22 +1,25 @@ -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/db"; import { getSetting } from "@/db/queries"; -import { - builds, - githubInstallations, - githubRepos, - projects, - secrets, - services, -} from "@/db/schema"; +import { builds, serviceRevisions, services } from "@/db/schema"; import { verifyAgentRequest } from "@/lib/agent-auth"; -import { buildCloneUrl, getInstallationToken } from "@/lib/github"; +import { cloneUrlForRevisionSource } from "@/lib/build-revision-source"; +import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; +import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; import { DEFAULT_BUILD_TIMEOUT_MINUTES, SETTING_KEYS, } from "@/lib/settings-keys"; +function imageRepository(image: string): string { + const digestIndex = image.indexOf("@"); + if (digestIndex > 0) return image.slice(0, digestIndex); + const lastColon = image.lastIndexOf(":"); + return lastColon > image.lastIndexOf("/") ? image.slice(0, lastColon) : image; +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> }, @@ -28,8 +31,7 @@ export async function POST( const { id: buildId } = await params; const { serverId } = auth; - - const claimResult = await db + const build = await db .update(builds) .set({ status: "claimed", @@ -37,9 +39,8 @@ export async function POST( claimedAt: new Date(), }) .where(and(eq(builds.id, buildId), eq(builds.status, "pending"))) - .returning(); - - const build = claimResult[0]; + .returning() + .then((rows) => rows[0]); if (!build) { return NextResponse.json( @@ -48,138 +49,109 @@ export async function POST( ); } - const service = await db - .select() - .from(services) - .where(and(eq(services.id, build.serviceId), isNull(services.deletedAt))) - .then((r) => r[0]); - - if (!service) { - await db + const failClaim = async (message: string, status = 500) => { + const failed = await db .update(builds) - .set({ - status: "failed", - error: "Service not found", - completedAt: new Date(), - }) - .where(eq(builds.id, buildId)); - return NextResponse.json({ error: "Service not found" }, { status: 404 }); - } - - const project = await db - .select() - .from(projects) - .where(eq(projects.id, service.projectId)) - .then((r) => r[0]); - - if (!project) { - return NextResponse.json({ error: "Project not found" }, { status: 404 }); - } - - const registryHost = process.env.REGISTRY_HOST; - if (!registryHost) { - return NextResponse.json( - { error: "REGISTRY_HOST environment variable is required" }, - { status: 500 }, - ); - } - const imageRepository = `${registryHost}/${project.id}/${service.id}`; - const commitSha = build.commitSha === "HEAD" ? "latest" : build.commitSha; - const imageUri = `${imageRepository}:${commitSha}`; - - let cloneUrl: string; - - if (build.githubRepoId) { - const githubRepo = await db - .select() - .from(githubRepos) - .where(eq(githubRepos.id, build.githubRepoId)) - .then((r) => r[0]); - - if (!githubRepo) { + .set({ status: "failed", error: message, completedAt: new Date() }) + .where( + and( + eq(builds.id, buildId), + eq(builds.claimedBy, serverId), + inArray(builds.status, ["claimed", "cloning", "building", "pushing"]), + ), + ) + .returning({ id: builds.id }) + .then((rows) => rows[0]); + if (!failed) { return NextResponse.json( - { error: "GitHub repo not found" }, - { status: 404 }, + { error: "Build was cancelled while being claimed" }, + { status: 409 }, ); } - - const installation = await db - .select() - .from(githubInstallations) - .where(eq(githubInstallations.installationId, githubRepo.installationId)) - .then((r) => r[0]); - - if (!installation) { - return NextResponse.json( - { error: "GitHub installation not found" }, - { status: 404 }, - ); - } - - let installationToken: string; - try { - installationToken = await getInstallationToken( - installation.installationId, - ); - } catch (error) { - console.error("[build:get] failed to get installation token:", error); - await db - .update(builds) - .set({ + await inngest.send( + inngestEvents.buildCompleted.create( + { + buildId, + serviceId: build.serviceId, + serviceRevisionId: build.serviceRevisionId, + buildGroupId: build.buildGroupId, status: "failed", - error: "Failed to get GitHub installation token", - }) - .where(eq(builds.id, buildId)); - return NextResponse.json( - { error: "Failed to get GitHub installation token" }, - { status: 500 }, - ); - } - - cloneUrl = buildCloneUrl(installationToken, githubRepo.repoFullName); - } else if (service.githubRepoUrl) { - cloneUrl = service.githubRepoUrl; - if (!cloneUrl.endsWith(".git")) { - cloneUrl = `${cloneUrl}.git`; - } - } else { - return NextResponse.json( - { error: "No repository configured" }, - { status: 400 }, + error: message, + }, + { + id: `build-completed-${buildId}`, + }, + ), ); - } - - const [serviceSecrets, buildTimeoutMinutes] = await Promise.all([ - db.select().from(secrets).where(eq(secrets.serviceId, service.id)), + return NextResponse.json({ error: message }, { status }); + }; + + const [service, revision, buildTimeoutMinutes] = await Promise.all([ + db + .select({ id: services.id, projectId: services.projectId }) + .from(services) + .where(eq(services.id, build.serviceId)) + .then((rows) => rows[0]), + db + .select({ specification: serviceRevisions.specification }) + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.id, build.serviceRevisionId), + eq(serviceRevisions.serviceId, build.serviceId), + ), + ) + .then((rows) => rows[0]), getSetting(SETTING_KEYS.BUILD_TIMEOUT_MINUTES), ]); + if (!service) return failClaim("Service not found", 404); + if (!revision) return failClaim("Build service revision not found", 404); + + let specification: ReturnType; + try { + specification = parseServiceRevisionSpec(revision.specification); + } catch (error) { + console.error("[build:get] invalid service revision:", error); + return failClaim("Invalid build service revision"); + } + if ( + specification.source.type !== "github" || + specification.source.commitSha !== build.commitSha || + specification.source.branch !== build.branch + ) { + return failClaim("Build metadata does not match its service revision"); + } - const secretsMap: Record = {}; - for (const secret of serviceSecrets) { - secretsMap[secret.key] = secret.encryptedValue; + let cloneUrl: string; + try { + cloneUrl = await cloneUrlForRevisionSource(specification.source); + } catch (error) { + console.error("[build:get] failed to get installation token:", error); + return failClaim("Failed to get GitHub installation token"); } - const targetPlatforms = build.targetPlatform - ? [build.targetPlatform] - : ["linux/amd64"]; + const secretsMap = Object.fromEntries( + specification.secrets.map((secret) => [secret.key, secret.encryptedValue]), + ); + const targetPlatforms = [build.targetPlatform]; console.log( - `[build:get] build ${buildId.slice(0, 8)} details fetched by ${serverId.slice(0, 8)}, image: ${imageUri}`, + `[build:get] build ${buildId.slice(0, 8)} details fetched by ${serverId.slice(0, 8)}, revision: ${build.serviceRevisionId.slice(0, 8)}, image: ${specification.image}`, ); return NextResponse.json({ build: { id: build.id, - commitSha: build.commitSha, + commitSha: specification.source.commitSha, commitMessage: build.commitMessage, - branch: build.branch, + branch: specification.source.branch, serviceId: build.serviceId, - projectId: project.id, + projectId: service.projectId, }, cloneUrl, - imageRepository, - imageUri, - rootDir: service.githubRootDir || "", + imageRepository: imageRepository(specification.image), + imageUri: specification.image, + rootDir: specification.source.rootDir ?? "", secrets: secretsMap, timeoutMinutes: buildTimeoutMinutes ?? DEFAULT_BUILD_TIMEOUT_MINUTES, targetPlatforms, diff --git a/web/app/api/v1/agent/builds/[id]/status/route.ts b/web/app/api/v1/agent/builds/[id]/status/route.ts index 87d03dc1..4a5041b6 100644 --- a/web/app/api/v1/agent/builds/[id]/status/route.ts +++ b/web/app/api/v1/agent/builds/[id]/status/route.ts @@ -1,19 +1,14 @@ -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/db"; -import { - builds, - githubRepos, - projects, - serviceReplicas, - services, -} from "@/db/schema"; +import { builds, serviceRevisions } from "@/db/schema"; import { verifyAgentRequest } from "@/lib/agent-auth"; -import { deployServiceInternal } from "@/lib/deploy-service"; +import { revisionRepositoryFullName } from "@/lib/build-revision-source"; import { sendBuildFailureAlert } from "@/lib/email"; import { updateGitHubDeploymentStatus } from "@/lib/github"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; +import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; import { enqueueWork } from "@/lib/work-queue"; type StatusUpdate = { @@ -22,17 +17,55 @@ type StatusUpdate = { resolvedCommitSha?: string; }; -type BuildCompletedEventData = { +const validStatuses = new Set([ + "cloning", + "building", + "pushing", + "completed", + "failed", +]); + +type BuildStatus = typeof builds.$inferSelect.status; +const terminalBuildStatuses = new Set([ + "completed", + "failed", + "cancelled", +]); +const transitionSources: Record = { + cloning: ["pending", "claimed", "cloning"], + building: ["pending", "claimed", "cloning", "building"], + pushing: ["pending", "claimed", "cloning", "building", "pushing"], + completed: ["pending", "claimed", "cloning", "building", "pushing"], + failed: ["pending", "claimed", "cloning", "building", "pushing"], +}; + +function platformImageForTarget(finalImage: string, targetPlatform: string) { + const [operatingSystem, architecture, ...extra] = targetPlatform.split("/"); + if ( + operatingSystem !== "linux" || + !architecture || + extra.length > 0 || + !["amd64", "arm64"].includes(architecture) + ) { + throw new Error(`Invalid build target platform: ${targetPlatform}`); + } + return `${finalImage}-${architecture}`; +} + +async function sendBuildCompletedEvent(data: { buildId: string; serviceId: string; - buildGroupId: string | null; + serviceRevisionId: string; + buildGroupId: string; status: "success" | "failed"; imageUri?: string; error?: string; -}; - -async function sendBuildCompletedEvent(data: BuildCompletedEventData) { - await inngest.send(inngestEvents.buildCompleted.create(data)); +}) { + await inngest.send( + inngestEvents.buildCompleted.create(data, { + id: `build-completed-${data.buildId}`, + }), + ); } export async function POST( @@ -46,106 +79,198 @@ export async function POST( } const { id: buildId } = await params; - const { serverId } = auth; - let update: StatusUpdate; try { update = JSON.parse(body); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } + if (!validStatuses.has(update.status)) { + return NextResponse.json( + { error: "Invalid build status" }, + { status: 400 }, + ); + } - const [build] = await db + const build = await db .select() .from(builds) - .where(and(eq(builds.id, buildId), eq(builds.claimedBy, serverId))); - + .where(and(eq(builds.id, buildId), eq(builds.claimedBy, auth.serverId))) + .then((rows) => rows[0]); if (!build) { return NextResponse.json( { error: "Build not found or not claimed by this agent" }, { status: 404 }, ); } - if (build.status === "cancelled") { return NextResponse.json({ ok: true, cancelled: true }); } - const updateData: Record = { status: update.status }; + const revision = await db + .select({ specification: serviceRevisions.specification }) + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.id, build.serviceRevisionId), + eq(serviceRevisions.serviceId, build.serviceId), + ), + ) + .then((rows) => rows[0]); + if (!revision) { + return NextResponse.json( + { error: "Build service revision not found" }, + { status: 404 }, + ); + } + + let specification: ReturnType; + try { + specification = parseServiceRevisionSpec(revision.specification); + } catch (error) { + console.error("[build:status] invalid service revision:", error); + return NextResponse.json( + { error: "Invalid build service revision" }, + { status: 500 }, + ); + } + if ( + specification.source.type !== "github" || + specification.source.commitSha !== build.commitSha || + specification.source.branch !== build.branch + ) { + return NextResponse.json( + { error: "Build metadata does not match its service revision" }, + { status: 409 }, + ); + } + if ( + update.resolvedCommitSha && + update.resolvedCommitSha.toLowerCase() !== specification.source.commitSha + ) { + return NextResponse.json( + { error: "Resolved commit does not match the service revision" }, + { status: 409 }, + ); + } + + let platformImageUri: string | null = null; + if (update.status === "completed") { + try { + platformImageUri = platformImageForTarget( + specification.image, + build.targetPlatform, + ); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Invalid build target", + }, + { status: 500 }, + ); + } + } + const updateData: Record = { status: update.status }; if (update.status === "cloning" && !build.startedAt) { updateData.startedAt = new Date(); } - if (update.status === "completed" || update.status === "failed") { updateData.completedAt = new Date(); } - - if (update.error) { - updateData.error = update.error; - } - - const effectiveCommitSha = - build.commitSha === "HEAD" && update.resolvedCommitSha - ? update.resolvedCommitSha - : build.commitSha; - - if (build.commitSha === "HEAD" && update.resolvedCommitSha) { - updateData.commitSha = update.resolvedCommitSha; + if (update.error) updateData.error = update.error; + if (platformImageUri) updateData.imageUri = platformImageUri; + + const transitionedBuild = await db + .update(builds) + .set(updateData) + .where( + and( + eq(builds.id, buildId), + eq(builds.claimedBy, auth.serverId), + inArray(builds.status, transitionSources[update.status]), + ), + ) + .returning() + .then((rows) => rows[0]); + let replayingTerminalUpdate = false; + if (!transitionedBuild) { + const currentBuild = await db + .select() + .from(builds) + .where(and(eq(builds.id, buildId), eq(builds.claimedBy, auth.serverId))) + .then((rows) => rows[0]); + if (!currentBuild) { + return NextResponse.json( + { error: "Build not found or not claimed by this agent" }, + { status: 404 }, + ); + } + if (currentBuild.status === "cancelled") { + return NextResponse.json({ ok: true, cancelled: true }); + } + if ( + !terminalBuildStatuses.has(currentBuild.status) || + currentBuild.status !== update.status + ) { + return NextResponse.json( + { + error: `Cannot change build status from ${currentBuild.status} to ${update.status}`, + }, + { status: 409 }, + ); + } + if ( + update.status === "completed" && + currentBuild.imageUri !== platformImageUri + ) { + return NextResponse.json( + { + error: "Completed build artifact does not match its service revision", + }, + { status: 409 }, + ); + } + replayingTerminalUpdate = true; } - await db.update(builds).set(updateData).where(eq(builds.id, buildId)); - - if (build.githubDeploymentId && build.githubRepoId) { + if ( + !replayingTerminalUpdate && + build.githubDeploymentId && + specification.source.authentication.type === "github_app" + ) { try { - const githubRepo = await db - .select() - .from(githubRepos) - .where(eq(githubRepos.id, build.githubRepoId)) - .then((r) => r[0]); - - if (githubRepo) { - const baseUrl = process.env.APP_URL || "https://cloud.techulus.com"; - const logUrl = `${baseUrl}/builds/${buildId}/logs`; - - if ( - update.status === "cloning" || - update.status === "building" || - update.status === "pushing" - ) { - await updateGitHubDeploymentStatus( - githubRepo.installationId, - githubRepo.repoFullName, - build.githubDeploymentId, - "in_progress", - { - description: `Build ${update.status}...`, - logUrl, - }, - ); - } else if (update.status === "completed") { - await updateGitHubDeploymentStatus( - githubRepo.installationId, - githubRepo.repoFullName, - build.githubDeploymentId, - "success", - { - description: "Build completed successfully", - logUrl, - }, - ); - } else if (update.status === "failed") { - await updateGitHubDeploymentStatus( - githubRepo.installationId, - githubRepo.repoFullName, - build.githubDeploymentId, - "failure", - { - description: update.error || "Build failed", - logUrl, - }, - ); - } + const baseUrl = process.env.APP_URL || "https://cloud.techulus.com"; + const logUrl = `${baseUrl}/builds/${buildId}/logs`; + const repository = revisionRepositoryFullName( + specification.source.repository, + ); + const installationId = specification.source.authentication.installationId; + if (["cloning", "building", "pushing"].includes(update.status)) { + await updateGitHubDeploymentStatus( + installationId, + repository, + build.githubDeploymentId, + "in_progress", + { description: `Build ${update.status}...`, logUrl }, + ); + } else if (update.status === "completed") { + await updateGitHubDeploymentStatus( + installationId, + repository, + build.githubDeploymentId, + "success", + { description: "Build completed successfully", logUrl }, + ); + } else { + await updateGitHubDeploymentStatus( + installationId, + repository, + build.githubDeploymentId, + "failure", + { description: update.error || "Build failed", logUrl }, + ); } } catch (error) { console.error( @@ -156,20 +281,22 @@ export async function POST( } if (update.status === "failed") { - sendBuildFailureAlert({ - serviceId: build.serviceId, - buildId, - error: update.error, - }).catch((error) => { - console.error( - "[build:status] failed to send build failure alert:", - error, - ); - }); - + if (!replayingTerminalUpdate) { + sendBuildFailureAlert({ + serviceId: build.serviceId, + buildId, + error: update.error, + }).catch((error) => { + console.error( + "[build:status] failed to send build failure alert:", + error, + ); + }); + } await sendBuildCompletedEvent({ buildId, serviceId: build.serviceId, + serviceRevisionId: build.serviceRevisionId, buildGroupId: build.buildGroupId, status: "failed", error: update.error, @@ -177,209 +304,62 @@ export async function POST( } if (update.status === "completed") { - console.log( - `[build:status] build ${buildId.slice(0, 8)} completed, targetPlatform=${build.targetPlatform}, serviceId=${build.serviceId}, commitSha=${effectiveCommitSha.slice(0, 8)}`, - ); - - const service = await db - .select() - .from(services) - .where(and(eq(services.id, build.serviceId), isNull(services.deletedAt))) - .then((r) => r[0]); - - if (!service) { - await db - .update(builds) - .set({ error: "Service not found" }) - .where(eq(builds.id, buildId)); - return NextResponse.json({ error: "Service not found" }, { status: 404 }); - } - - const project = await db - .select() - .from(projects) - .where(eq(projects.id, service.projectId)) - .then((r) => r[0]); - - if (!project) { - return NextResponse.json({ error: "Project not found" }, { status: 404 }); - } - - const registryHost = process.env.REGISTRY_HOST; - if (!registryHost) { + if (!platformImageUri) { return NextResponse.json( - { error: "REGISTRY_HOST environment variable is required" }, + { error: "Invalid build artifact" }, { status: 500 }, ); } - const commitSha = - effectiveCommitSha === "HEAD" ? "latest" : effectiveCommitSha; - const baseImageUri = `${registryHost}/${project.id}/${service.id}:${commitSha}`; - - if (build.targetPlatform) { - const arch = build.targetPlatform.split("/")[1]; - const archImageUri = `${baseImageUri}-${arch}`; - await db - .update(builds) - .set({ imageUri: archImageUri }) - .where(eq(builds.id, buildId)); - if (!build.buildGroupId) { - console.log(`[build:status] no buildGroupId, treating as single build`); - - const replicas = await db - .select() - .from(serviceReplicas) - .where(eq(serviceReplicas.serviceId, build.serviceId)); - - const shouldDeploy = replicas.some((replica) => replica.count > 0); - - console.log( - `[build:complete] enqueueing create_manifest for single-target build ${baseImageUri} to server ${serverId.slice(0, 8)}`, - ); - await enqueueWork(serverId, "create_manifest", { - images: [archImageUri], - finalImageUri: baseImageUri, - serviceId: shouldDeploy ? build.serviceId : undefined, - buildId, - }); - - await db - .update(services) - .set({ image: baseImageUri }) - .where( - and(eq(services.id, build.serviceId), isNull(services.deletedAt)), - ); - - await sendBuildCompletedEvent({ - buildId, - serviceId: build.serviceId, - buildGroupId: build.buildGroupId, - status: "success", - imageUri: archImageUri, - }); - - console.log( - `[build:status] build ${buildId.slice(0, 8)} status: ${update.status}`, - ); - return NextResponse.json({ ok: true }); - } - - const groupBuilds = await db - .select() - .from(builds) - .where(eq(builds.buildGroupId, build.buildGroupId)); - - console.log( - `[build:status] groupBuilds found: ${groupBuilds.length}, statuses: ${groupBuilds.map((b) => `${b.id.slice(0, 8)}=${b.status}`).join(", ")}`, - ); - - const allCompleted = groupBuilds.every( - (b) => b.id === buildId || b.status === "completed", + const groupBuilds = await db + .select() + .from(builds) + .where( + and( + eq(builds.buildGroupId, build.buildGroupId), + eq(builds.serviceRevisionId, build.serviceRevisionId), + ), ); - - console.log(`[build:status] allCompleted=${allCompleted}`); - - if (allCompleted && groupBuilds.length > 0) { - console.log( - `[build:complete] all ${groupBuilds.length} platform builds completed for ${build.serviceId}@${commitSha.slice(0, 8)}`, + const allCompleted = + groupBuilds.length > 0 && + groupBuilds.every((candidate) => { + if (candidate.status !== "completed") return false; + return ( + candidate.imageUri === + platformImageForTarget(specification.image, candidate.targetPlatform) ); - - const images = groupBuilds.map((b) => { - const bArch = b.targetPlatform?.split("/")[1] || "amd64"; - return `${baseImageUri}-${bArch}`; - }); - - const replicas = await db - .select() - .from(serviceReplicas) - .where(eq(serviceReplicas.serviceId, build.serviceId)); - - const shouldDeploy = replicas.some((replica) => replica.count > 0); - - console.log( - `[build:complete] enqueueing create_manifest for ${baseImageUri} to server ${serverId.slice(0, 8)}`, - ); - await enqueueWork(serverId, "create_manifest", { + }); + if (allCompleted) { + const images = groupBuilds.map((candidate) => + platformImageForTarget(specification.image, candidate.targetPlatform), + ); + await enqueueWork( + auth.serverId, + "create_manifest", + { images, - finalImageUri: baseImageUri, - serviceId: shouldDeploy ? build.serviceId : undefined, + finalImageUri: specification.image, + serviceId: build.serviceId, + serviceRevisionId: build.serviceRevisionId, buildGroupId: build.buildGroupId, - }); - - await db - .update(services) - .set({ image: baseImageUri }) - .where( - and(eq(services.id, build.serviceId), isNull(services.deletedAt)), - ); - } - - await sendBuildCompletedEvent({ - buildId, - serviceId: build.serviceId, - buildGroupId: build.buildGroupId, - status: "success", - imageUri: `${baseImageUri}-${build.targetPlatform?.split("/")[1] || "amd64"}`, - }); - } else { - console.log( - `[build:status] no targetPlatform, using legacy single-build path`, + }, + { id: `manifest-work-${build.buildGroupId}` }, ); - - await db - .update(builds) - .set({ imageUri: baseImageUri }) - .where(eq(builds.id, buildId)); - - await db - .update(services) - .set({ image: baseImageUri }) - .where( - and(eq(services.id, build.serviceId), isNull(services.deletedAt)), - ); - - const replicas = await db - .select() - .from(serviceReplicas) - .where(eq(serviceReplicas.serviceId, build.serviceId)); - - const shouldDeploy = replicas.some((replica) => replica.count > 0); - - if (shouldDeploy) { - console.log( - `[build:complete] triggering deployment for service ${build.serviceId}`, - ); - - try { - await deployServiceInternal(build.serviceId, null); - } catch (error) { - console.error("[build:complete] deployment failed:", error); - await db - .update(builds) - .set({ error: `Deployment failed: ${error}` }) - .where(eq(builds.id, buildId)); - } - } else { - console.log( - `[build:complete] no replicas configured for service ${build.serviceId}, skipping deployment`, - ); - } - - await sendBuildCompletedEvent({ - buildId, - serviceId: build.serviceId, - buildGroupId: build.buildGroupId, - status: "success", - imageUri: baseImageUri, - }); } + + await sendBuildCompletedEvent({ + buildId, + serviceId: build.serviceId, + serviceRevisionId: build.serviceRevisionId, + buildGroupId: build.buildGroupId, + status: "success", + imageUri: platformImageUri, + }); } console.log( - `[build:status] build ${buildId.slice(0, 8)} status: ${update.status}`, + `[build:status] build ${buildId.slice(0, 8)} status: ${update.status}, revision=${build.serviceRevisionId.slice(0, 8)}`, ); - return NextResponse.json({ ok: true }); } @@ -391,17 +371,14 @@ export async function GET( if (!auth.success) { return NextResponse.json({ error: auth.error }, { status: auth.status }); } - const { id: buildId } = await params; - - const [build] = await db + const build = await db .select({ status: builds.status }) .from(builds) - .where(eq(builds.id, buildId)); - + .where(eq(builds.id, buildId)) + .then((rows) => rows[0]); if (!build) { return NextResponse.json({ error: "Build not found" }, { status: 404 }); } - return NextResponse.json({ status: build.status }); } diff --git a/web/app/api/v1/api-keys/route.ts b/web/app/api/v1/api-keys/route.ts new file mode 100644 index 00000000..1cdadf70 --- /dev/null +++ b/web/app/api/v1/api-keys/route.ts @@ -0,0 +1,41 @@ +export const dynamic = "force-dynamic"; + +import { z } from "zod"; +import { requireRequestRole } from "@/lib/api-auth"; +import { auth as betterAuth } from "@/lib/auth"; +import { apiError, badRequest } from "@/lib/public-api"; + +const schema = z.strictObject({ + name: z.string().trim().min(1).max(32), + metadata: z.record(z.string(), z.unknown()).optional(), +}); +export async function POST(request: Request) { + if (request.headers.has("x-api-key")) + return apiError( + "API keys cannot create API keys", + "API_KEY_AUTH_FORBIDDEN", + 403, + ); + const session = await requireRequestRole(request, [ + "admin", + "developer", + "reader", + ]); + if (!session.ok) return session.response; + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) + return badRequest(parsed.error.issues[0]?.message ?? "Invalid request"); + try { + const key = await betterAuth.api.createApiKey({ + headers: request.headers, + body: parsed.data, + }); + return Response.json( + { apiKey: key.key, keyId: key.id, name: key.name }, + { status: 201 }, + ); + } catch (error) { + console.error("[public-api] API key creation failed", error); + return apiError("Failed to create API key", "API_KEY_CREATE_FAILED", 500); + } +} diff --git a/web/app/api/v1/cli/auth/exchange/route.ts b/web/app/api/v1/cli/auth/exchange/route.ts deleted file mode 100644 index 43fc0797..00000000 --- a/web/app/api/v1/cli/auth/exchange/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -export const dynamic = "force-dynamic"; - -import { z } from "zod"; -import { requireRequestRole } from "@/lib/api-auth"; -import { auth } from "@/lib/auth"; - -const exchangeSchema = z - .object({ - machineName: z.string().trim().min(1).max(128).optional(), - platform: z.string().trim().min(1).max(128).optional(), - cliVersion: z.string().trim().min(1).max(64).optional(), - }) - .strict(); - -export async function POST(request: Request) { - const sessionResult = await requireRequestRole(request, [ - "admin", - "developer", - "reader", - ]); - if (!sessionResult.ok) { - return sessionResult.response; - } - - const body = await request.json().catch(() => ({})); - const parsed = exchangeSchema.safeParse(body); - - if (!parsed.success) { - return Response.json( - { error: parsed.error.issues[0]?.message || "Invalid request" }, - { status: 400 }, - ); - } - - const metadata = { - creationSource: "techulus-cli", - machineName: parsed.data.machineName ?? null, - platform: parsed.data.platform ?? null, - cliVersion: parsed.data.cliVersion ?? null, - host: new URL(request.url).origin, - }; - - const name = parsed.data.machineName - ? `CLI - ${parsed.data.machineName}`.slice(0, 32) - : "CLI"; - - const apiKey = await auth.api.createApiKey({ - headers: request.headers, - body: { - name, - metadata, - }, - }); - - return Response.json({ - apiKey: apiKey.key, - keyId: apiKey.id, - name: apiKey.name, - user: sessionResult.session.user, - }); -} diff --git a/web/app/api/v1/cli/auth/whoami/route.ts b/web/app/api/v1/cli/auth/whoami/route.ts deleted file mode 100644 index eab645e8..00000000 --- a/web/app/api/v1/cli/auth/whoami/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const dynamic = "force-dynamic"; - -import { requireRequestRole } from "@/lib/api-auth"; - -export async function GET(request: Request) { - const sessionResult = await requireRequestRole(request, [ - "admin", - "developer", - "reader", - ]); - if (!sessionResult.ok) { - return sessionResult.response; - } - - return Response.json({ - user: sessionResult.session.user, - session: { - id: sessionResult.session.session.id, - expiresAt: sessionResult.session.session.expiresAt, - }, - }); -} diff --git a/web/app/api/v1/manifest/apply/route.ts b/web/app/api/v1/manifest/apply/route.ts deleted file mode 100644 index 734bcaeb..00000000 --- a/web/app/api/v1/manifest/apply/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -export const dynamic = "force-dynamic"; - -import { requireRequestDeveloperRole } from "@/lib/api-auth"; -import { techulusManifestSchema } from "@/lib/cli-manifest"; -import { applyManifest } from "@/lib/cli-service"; - -export async function POST(request: Request) { - const sessionResult = await requireRequestDeveloperRole(request); - if (!sessionResult.ok) { - return sessionResult.response; - } - - const body = await request.json().catch(() => null); - const parsed = techulusManifestSchema.safeParse(body); - - if (!parsed.success) { - return Response.json( - { error: parsed.error.issues[0]?.message || "Invalid manifest" }, - { status: 400 }, - ); - } - - try { - const result = await applyManifest(parsed.data); - return Response.json(result); - } catch (error) { - return Response.json( - { - error: - error instanceof Error ? error.message : "Failed to apply manifest", - }, - { status: 400 }, - ); - } -} diff --git a/web/app/api/v1/manifest/deploy/route.ts b/web/app/api/v1/manifest/deploy/route.ts deleted file mode 100644 index ee96d407..00000000 --- a/web/app/api/v1/manifest/deploy/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -export const dynamic = "force-dynamic"; - -import { requireRequestDeveloperRole } from "@/lib/api-auth"; -import { techulusManifestSchema } from "@/lib/cli-manifest"; -import { deployManifest } from "@/lib/cli-service"; - -export async function POST(request: Request) { - const sessionResult = await requireRequestDeveloperRole(request); - if (!sessionResult.ok) { - return sessionResult.response; - } - - const body = await request.json().catch(() => null); - const parsed = techulusManifestSchema.safeParse(body); - - if (!parsed.success) { - return Response.json( - { error: parsed.error.issues[0]?.message || "Invalid manifest" }, - { status: 400 }, - ); - } - - try { - const result = await deployManifest(parsed.data, { - type: "user", - userId: sessionResult.session.user.id, - name: sessionResult.session.user.name, - }); - return Response.json(result); - } catch (error) { - return Response.json( - { - error: - error instanceof Error ? error.message : "Failed to deploy manifest", - }, - { status: 400 }, - ); - } -} diff --git a/web/app/api/v1/manifest/link-targets/route.ts b/web/app/api/v1/manifest/link-targets/route.ts deleted file mode 100644 index bac944f2..00000000 --- a/web/app/api/v1/manifest/link-targets/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const dynamic = "force-dynamic"; - -import { requireRequestRole } from "@/lib/api-auth"; -import { listLinkTargets } from "@/lib/cli-service"; - -export async function GET(request: Request) { - const sessionResult = await requireRequestRole(request, [ - "admin", - "developer", - "reader", - ]); - if (!sessionResult.ok) { - return sessionResult.response; - } - - try { - const result = await listLinkTargets(); - return Response.json(result); - } catch (error) { - console.error("Failed to list link targets", error); - return Response.json( - { error: "Failed to list link targets" }, - { status: 500 }, - ); - } -} diff --git a/web/app/api/v1/manifest/link/route.ts b/web/app/api/v1/manifest/link/route.ts deleted file mode 100644 index 731d87f6..00000000 --- a/web/app/api/v1/manifest/link/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -export const dynamic = "force-dynamic"; - -import { z } from "zod"; -import { requireRequestRole } from "@/lib/api-auth"; -import { exportManifestForLinkedService } from "@/lib/cli-service"; - -const bodySchema = z.object({ - serviceId: z.string().trim().min(1), -}); - -export async function POST(request: Request) { - const sessionResult = await requireRequestRole(request, [ - "admin", - "developer", - "reader", - ]); - if (!sessionResult.ok) { - return sessionResult.response; - } - - const body = await request.json().catch(() => null); - const parsed = bodySchema.safeParse(body); - - if (!parsed.success) { - return Response.json( - { error: parsed.error.issues[0]?.message || "Invalid request" }, - { status: 400 }, - ); - } - - try { - const result = await exportManifestForLinkedService(parsed.data.serviceId); - return Response.json(result); - } catch (error) { - return Response.json( - { - error: - error instanceof Error ? error.message : "Failed to link service", - }, - { status: 400 }, - ); - } -} diff --git a/web/app/api/v1/manifest/logs/route.ts b/web/app/api/v1/manifest/logs/route.ts deleted file mode 100644 index 0404405c..00000000 --- a/web/app/api/v1/manifest/logs/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -export const dynamic = "force-dynamic"; - -import { z } from "zod"; -import { requireRequestRole } from "@/lib/api-auth"; -import { getManifestStatus } from "@/lib/cli-service"; -import { isLogCursor } from "@/lib/log-query"; -import { slugify } from "@/lib/utils"; -import { isLoggingEnabled, queryLogsByService } from "@/lib/victoria-logs"; - -const querySchema = z.object({ - project: z.string().trim().min(1), - environment: z.string().trim().min(1), - service: z.string().trim().min(1), - after: z - .string() - .trim() - .min(1) - .refine(isLogCursor, { message: "Invalid log cursor" }) - .optional(), - tail: z.coerce.number().int().min(1).max(1000).default(100), -}); - -export async function GET(request: Request) { - const sessionResult = await requireRequestRole(request, [ - "admin", - "developer", - "reader", - ]); - if (!sessionResult.ok) { - return sessionResult.response; - } - - const { searchParams } = new URL(request.url); - const parsed = querySchema.safeParse({ - project: searchParams.get("project"), - environment: searchParams.get("environment"), - service: searchParams.get("service"), - after: searchParams.get("after") ?? undefined, - tail: searchParams.get("tail") ?? undefined, - }); - - if (!parsed.success) { - return Response.json( - { error: parsed.error.issues[0]?.message || "Invalid request" }, - { status: 400 }, - ); - } - - const status = await getManifestStatus({ - project: slugify(parsed.data.project), - environment: parsed.data.environment, - service: parsed.data.service, - }); - - if (!status) { - return Response.json({ error: "Service not found" }, { status: 404 }); - } - - if (!isLoggingEnabled()) { - return Response.json({ loggingEnabled: false, logs: [] }); - } - - try { - const result = await queryLogsByService({ - serviceId: status.service.id, - limit: parsed.data.tail, - after: parsed.data.after, - logType: "container", - }); - - const logs = result.logs - .map((log) => ({ - deploymentId: log.deployment_id, - stream: log.stream || "stdout", - message: log._msg, - timestamp: log._time, - })) - .reverse(); - - return Response.json({ loggingEnabled: true, logs }); - } catch (error) { - console.error("[logs:manifest] failed to query logs:", error); - return Response.json({ loggingEnabled: true, logs: [] }); - } -} diff --git a/web/app/api/v1/manifest/status/route.ts b/web/app/api/v1/manifest/status/route.ts deleted file mode 100644 index 52fe8822..00000000 --- a/web/app/api/v1/manifest/status/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -export const dynamic = "force-dynamic"; - -import { z } from "zod"; -import { requireRequestRole } from "@/lib/api-auth"; -import { getManifestStatus } from "@/lib/cli-service"; -import { slugify } from "@/lib/utils"; - -const querySchema = z.object({ - project: z.string().trim().min(1), - environment: z.string().trim().min(1), - service: z.string().trim().min(1), -}); - -export async function GET(request: Request) { - const sessionResult = await requireRequestRole(request, [ - "admin", - "developer", - "reader", - ]); - if (!sessionResult.ok) { - return sessionResult.response; - } - - const { searchParams } = new URL(request.url); - const parsed = querySchema.safeParse({ - project: searchParams.get("project"), - environment: searchParams.get("environment"), - service: searchParams.get("service"), - }); - - if (!parsed.success) { - return Response.json( - { error: parsed.error.issues[0]?.message || "Invalid request" }, - { status: 400 }, - ); - } - - const status = await getManifestStatus({ - project: slugify(parsed.data.project), - environment: parsed.data.environment, - service: parsed.data.service, - }); - - if (!status) { - return Response.json({ error: "Service not found" }, { status: 404 }); - } - - return Response.json(status); -} diff --git a/web/app/api/v1/me/route.ts b/web/app/api/v1/me/route.ts new file mode 100644 index 00000000..cb18138f --- /dev/null +++ b/web/app/api/v1/me/route.ts @@ -0,0 +1,23 @@ +export const dynamic = "force-dynamic"; + +import { requireApiKeyRole } from "@/lib/api-auth"; +export async function GET(request: Request) { + const auth = await requireApiKeyRole(request, [ + "admin", + "developer", + "reader", + ]); + if (!auth.ok) return auth.response; + return Response.json({ + user: { + id: auth.session.user.id, + name: auth.session.user.name, + email: auth.session.user.email, + role: auth.session.user.role, + }, + session: { + id: auth.session.session.id, + expiresAt: auth.session.session.expiresAt, + }, + }); +} diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/builds/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/builds/route.ts new file mode 100644 index 00000000..0f349105 --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/builds/route.ts @@ -0,0 +1,2 @@ +export const dynamic = "force-dynamic"; +export { getBuilds as GET } 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/projects/[projectId]/environments/[environmentId]/services/[serviceId]/configuration/route.ts new file mode 100644 index 00000000..5468d6c1 --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/configuration/route.ts @@ -0,0 +1,5 @@ +export const dynamic = "force-dynamic"; +export { + getConfiguration as GET, + patchConfigurationRoute as PATCH, +} 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/projects/[projectId]/environments/[environmentId]/services/[serviceId]/deploy/route.ts new file mode 100644 index 00000000..48fc6a50 --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/deploy/route.ts @@ -0,0 +1,2 @@ +export const dynamic = "force-dynamic"; +export { postDeploy as POST } from "@/lib/public-api-routes"; diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/logs/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/logs/route.ts new file mode 100644 index 00000000..84724297 --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/logs/route.ts @@ -0,0 +1,2 @@ +export const dynamic = "force-dynamic"; +export { getServiceLogs as GET } from "@/lib/public-api-routes"; diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/metrics/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/metrics/route.ts new file mode 100644 index 00000000..18bcc9b5 --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/metrics/route.ts @@ -0,0 +1,2 @@ +export const dynamic = "force-dynamic"; +export { getMetrics as GET } from "@/lib/public-api-routes"; diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/revisions/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/revisions/route.ts new file mode 100644 index 00000000..840c2527 --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/revisions/route.ts @@ -0,0 +1,2 @@ +export const dynamic = "force-dynamic"; +export { getRevisions as GET } from "@/lib/public-api-routes"; diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/logs/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/logs/route.ts new file mode 100644 index 00000000..e1ca946e --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/logs/route.ts @@ -0,0 +1,2 @@ +export const dynamic = "force-dynamic"; +export { getRolloutLogs as GET } from "@/lib/public-api-routes"; diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/route.ts new file mode 100644 index 00000000..7701dc70 --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/[rolloutId]/route.ts @@ -0,0 +1,2 @@ +export const dynamic = "force-dynamic"; +export { getRollout as GET } from "@/lib/public-api-routes"; diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/route.ts new file mode 100644 index 00000000..8a454b44 --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/rollouts/route.ts @@ -0,0 +1,2 @@ +export const dynamic = "force-dynamic"; +export { getRollouts as GET } from "@/lib/public-api-routes"; 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 new file mode 100644 index 00000000..53272b0a --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/route.ts @@ -0,0 +1,49 @@ +export const dynamic = "force-dynamic"; + +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]/status/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/status/route.ts new file mode 100644 index 00000000..fdaa056c --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/[serviceId]/status/route.ts @@ -0,0 +1,2 @@ +export const dynamic = "force-dynamic"; +export { getStatus as GET } from "@/lib/public-api-routes"; diff --git a/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts new file mode 100644 index 00000000..63cc3186 --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/[environmentId]/services/route.ts @@ -0,0 +1,87 @@ +export const dynamic = "force-dynamic"; + +import { and, asc, eq, gt, isNull, or } from "drizzle-orm"; +import { db } from "@/db"; +import { environments, services } from "@/db/schema"; +import { requireApiKeyRole } from "@/lib/api-auth"; +import { + apiError, + badRequest, + notFound, + resolvePersistedSource, +} from "@/lib/public-api"; +import { namedPage, nextNamedCursor } from "@/lib/public-api-pagination"; + +export async function GET( + request: Request, + { + params, + }: { + params: Promise<{ projectId: string; environmentId: string }>; + }, +) { + const auth = await requireApiKeyRole(request, [ + "admin", + "developer", + "reader", + ]); + if (!auth.ok) return auth.response; + const { projectId, environmentId } = await params; + let page: ReturnType; + try { + page = namedPage(new URL(request.url)); + } catch (error) { + return badRequest((error as Error).message, "INVALID_PAGINATION"); + } + try { + const environment = await db + .select({ id: environments.id }) + .from(environments) + .where( + and( + eq(environments.id, environmentId), + eq(environments.projectId, projectId), + ), + ) + .limit(1) + .then((rows) => rows[0]); + if (!environment) return notFound(); + const rows = await db + .select() + .from(services) + .where( + and( + eq(services.projectId, projectId), + eq(services.environmentId, environmentId), + isNull(services.deletedAt), + page.cursor + ? or( + gt(services.name, page.cursor.name), + and( + eq(services.name, page.cursor.name), + gt(services.id, page.cursor.id), + ), + ) + : undefined, + ), + ) + .orderBy(asc(services.name), asc(services.id)) + .limit(page.limit + 1); + const pageRows = rows.slice(0, page.limit); + return Response.json({ + services: await Promise.all( + pageRows.map(async (service) => ({ + id: service.id, + name: service.name, + hostname: service.hostname, + source: await resolvePersistedSource(service), + createdAt: service.createdAt, + })), + ), + nextCursor: nextNamedCursor(rows, page.limit), + }); + } catch (error) { + console.error("[public-api] list services failed", error); + return apiError("Internal server error", "INTERNAL_ERROR", 500); + } +} diff --git a/web/app/api/v1/projects/[projectId]/environments/route.ts b/web/app/api/v1/projects/[projectId]/environments/route.ts new file mode 100644 index 00000000..1b99904b --- /dev/null +++ b/web/app/api/v1/projects/[projectId]/environments/route.ts @@ -0,0 +1,66 @@ +export const dynamic = "force-dynamic"; + +import { and, asc, eq, gt, or } from "drizzle-orm"; +import { db } from "@/db"; +import { environments, projects } from "@/db/schema"; +import { requireApiKeyRole } from "@/lib/api-auth"; +import { apiError, badRequest, notFound } from "@/lib/public-api"; +import { namedPage, nextNamedCursor } from "@/lib/public-api-pagination"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ projectId: string }> }, +) { + const auth = await requireApiKeyRole(request, [ + "admin", + "developer", + "reader", + ]); + if (!auth.ok) return auth.response; + const { projectId } = await params; + let page: ReturnType; + try { + page = namedPage(new URL(request.url)); + } catch (error) { + return badRequest((error as Error).message, "INVALID_PAGINATION"); + } + try { + const project = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1) + .then((rows) => rows[0]); + if (!project) return notFound(); + const items = await db + .select({ + id: environments.id, + name: environments.name, + createdAt: environments.createdAt, + }) + .from(environments) + .where( + and( + eq(environments.projectId, projectId), + page.cursor + ? or( + gt(environments.name, page.cursor.name), + and( + eq(environments.name, page.cursor.name), + gt(environments.id, page.cursor.id), + ), + ) + : undefined, + ), + ) + .orderBy(asc(environments.name), asc(environments.id)) + .limit(page.limit + 1); + return Response.json({ + environments: items.slice(0, page.limit), + nextCursor: nextNamedCursor(items, page.limit), + }); + } catch (error) { + console.error("[public-api] list environments failed", error); + return apiError("Internal server error", "INTERNAL_ERROR", 500); + } +} diff --git a/web/app/api/v1/projects/route.ts b/web/app/api/v1/projects/route.ts new file mode 100644 index 00000000..4f5285c3 --- /dev/null +++ b/web/app/api/v1/projects/route.ts @@ -0,0 +1,53 @@ +export const dynamic = "force-dynamic"; + +import { and, asc, eq, gt, or } from "drizzle-orm"; +import { db } from "@/db"; +import { projects } from "@/db/schema"; +import { requireApiKeyRole } from "@/lib/api-auth"; +import { apiError, badRequest } from "@/lib/public-api"; +import { namedPage, nextNamedCursor } from "@/lib/public-api-pagination"; + +export async function GET(request: Request) { + const auth = await requireApiKeyRole(request, [ + "admin", + "developer", + "reader", + ]); + if (!auth.ok) return auth.response; + let page: ReturnType; + try { + page = namedPage(new URL(request.url)); + } catch (error) { + return badRequest((error as Error).message, "INVALID_PAGINATION"); + } + try { + const rows = await db + .select({ + id: projects.id, + name: projects.name, + slug: projects.slug, + createdAt: projects.createdAt, + }) + .from(projects) + .where( + page.cursor + ? or( + gt(projects.name, page.cursor.name), + and( + eq(projects.name, page.cursor.name), + gt(projects.id, page.cursor.id), + ), + ) + : undefined, + ) + .orderBy(asc(projects.name), asc(projects.id)) + .limit(page.limit + 1); + return Response.json({ + projects: rows.slice(0, page.limit), + nextCursor: nextNamedCursor(rows, page.limit), + }); + } catch (error) { + console.error("[public-api] list projects failed", error); + return apiError("Internal server error", "INTERNAL_ERROR", 500); + } +} diff --git a/web/app/api/webhooks/github/route.ts b/web/app/api/webhooks/github/route.ts index d19c58b5..2e2bc453 100644 --- a/web/app/api/webhooks/github/route.ts +++ b/web/app/api/webhooks/github/route.ts @@ -12,8 +12,7 @@ import { updateGitHubDeploymentStatus, verifyWebhookSignature, } from "@/lib/github"; -import { inngest } from "@/lib/inngest/client"; -import { inngestEvents } from "@/lib/inngest/events"; +import { triggerResolvedBuildInternal } from "@/lib/trigger-build"; type InstallationPayload = { action: "created" | "deleted" | "suspend" | "unsuspend"; @@ -206,26 +205,21 @@ async function handlePushEvent(payload: PushPayload) { ); } - await inngest.send( - inngestEvents.buildTrigger.create( - { - serviceId: service.id, - trigger: "push", - githubRepoId: githubRepo.id, - commitSha: head_commit.id, - commitMessage: head_commit.message.substring(0, 500), - branch, - author: head_commit.author.username || head_commit.author.name, - githubDeploymentId, - actor: { - type: "github", - githubUserId: payload.sender.id, - login: payload.sender.login, - }, - }, - { id: `github-push:${githubRepo.id}:${head_commit.id}` }, - ), - ); + await triggerResolvedBuildInternal(service.id, { + trigger: "push", + commitSha: head_commit.id, + commitMessage: head_commit.message, + author: head_commit.author.username || head_commit.author.name, + expectedRepository: `https://github.com/${repository.full_name}`, + expectedBranch: branch, + githubDeploymentId, + idempotencyKey: `github-push:${githubRepo.id}:${head_commit.id}`, + actor: { + type: "github", + githubUserId: payload.sender.id, + login: payload.sender.login, + }, + }); results.push({ serviceId: service.id, status: "queued" }); } catch (error) { diff --git a/web/components/builds/build-details.tsx b/web/components/builds/build-details.tsx index 3430971a..b80b335b 100644 --- a/web/components/builds/build-details.tsx +++ b/web/components/builds/build-details.tsx @@ -28,7 +28,7 @@ import { ItemTitle, } from "@/components/ui/item"; import { StatusBadge } from "@/components/ui/status-badge"; -import type { Build, BuildStatus, GithubRepo, Service } from "@/db/types"; +import type { Build, BuildStatus, Service } from "@/db/types"; import { formatElapsedDurationBetween, formatRelativeTime } from "@/lib/date"; import { fetcher } from "@/lib/fetcher"; @@ -103,7 +103,7 @@ export function BuildDetails({ envName: string; service: Pick; build: BuildWithDates; - githubRepo: Pick | null; + githubRepo: { repoFullName: string } | null; }) { const router = useRouter(); const [isCancelling, setIsCancelling] = useState(false); diff --git a/web/components/service/service-layout-client.tsx b/web/components/service/service-layout-client.tsx index 62c8101d..b42a351c 100644 --- a/web/components/service/service-layout-client.tsx +++ b/web/components/service/service-layout-client.tsx @@ -92,6 +92,7 @@ export function ServiceLayoutClient({ ports, service.secrets, service.volumes, + service.currentSource, ); return diffConfigs(deployed, current); }, [service]); diff --git a/web/db/schema.ts b/web/db/schema.ts index 1fa54c8d..96646da1 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -697,7 +697,7 @@ export const rollouts = pgTable( serviceId: text("service_id") .notNull() .references(() => services.id, { onDelete: "cascade" }), - serviceRevisionId: text("service_revision_id"), + serviceRevisionId: text("service_revision_id").notNull(), status: text("status", { enum: ["queued", "in_progress", "completed", "failed", "rolled_back"], }) @@ -718,7 +718,9 @@ export const rollouts = pgTable( table.serviceId, table.createdAt, ), - index("rollouts_service_revision_id_idx").on(table.serviceRevisionId), + uniqueIndex("rollouts_service_revision_id_unique_idx").on( + table.serviceRevisionId, + ), foreignKey({ name: "rollouts_service_revision_service_fk", columns: [table.serviceRevisionId, table.serviceId], @@ -843,12 +845,10 @@ export const builds = pgTable( "builds", { id: text("id").primaryKey(), - githubRepoId: text("github_repo_id").references(() => githubRepos.id, { - onDelete: "cascade", - }), serviceId: text("service_id") .notNull() .references(() => services.id, { onDelete: "cascade" }), + serviceRevisionId: text("service_revision_id").notNull(), commitSha: text("commit_sha").notNull(), commitMessage: text("commit_message"), branch: text("branch").notNull(), @@ -870,8 +870,8 @@ export const builds = pgTable( imageUri: text("image_uri"), error: text("error"), githubDeploymentId: bigint("github_deployment_id", { mode: "number" }), - targetPlatform: text("target_platform"), - buildGroupId: text("build_group_id"), + targetPlatform: text("target_platform").notNull(), + buildGroupId: text("build_group_id").notNull(), claimedBy: text("claimed_by").references(() => servers.id, { onDelete: "set null", }), @@ -884,9 +884,14 @@ export const builds = pgTable( }, (table) => [ index("builds_service_created_at_idx").on(table.serviceId, table.createdAt), - index("builds_github_repo_id_idx").on(table.githubRepoId), + index("builds_service_revision_id_idx").on(table.serviceRevisionId), index("builds_build_group_id_idx").on(table.buildGroupId), index("builds_claimed_by_idx").on(table.claimedBy), + foreignKey({ + name: "builds_service_revision_service_fk", + columns: [table.serviceRevisionId, table.serviceId], + foreignColumns: [serviceRevisions.id, serviceRevisions.serviceId], + }).onDelete("cascade"), ], ); diff --git a/web/db/types.ts b/web/db/types.ts index 07a49834..809eedda 100644 --- a/web/db/types.ts +++ b/web/db/types.ts @@ -1,4 +1,4 @@ -import type { DeployedConfig } from "@/lib/service-config"; +import type { DeployedConfig, SourceConfig } from "@/lib/service-config"; import type { builds, deploymentPorts, @@ -56,6 +56,7 @@ export type HealthStats = { export type ServiceWithDetails = Service & { activeConfig?: DeployedConfig | null; + currentSource: SourceConfig; ports: ServicePort[]; configuredReplicas: Array< ServiceReplica & { serverName: string; serverIsProxy: boolean } diff --git a/web/lib/api-auth.ts b/web/lib/api-auth.ts index a411461a..6346feeb 100644 --- a/web/lib/api-auth.ts +++ b/web/lib/api-auth.ts @@ -1,3 +1,6 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { user } from "@/db/schema"; import type { MemberRole } from "@/db/types"; import { auth } from "@/lib/auth"; import { @@ -6,6 +9,15 @@ import { hasAnyRole, } from "@/lib/members"; +type AuthenticatedIdentity = { + user: { id: string; name: string; email: string }; + session: { id: string; expiresAt: Date }; +}; + +type SessionResult = + | { ok: true; session: AuthenticatedIdentity } + | { ok: false; response: Response }; + function getAuthErrorResponse(error: unknown) { if (!(error instanceof Error)) { return null; @@ -22,13 +34,47 @@ function getAuthErrorResponse(error: unknown) { return Response.json( { message: apiError.body?.message ?? apiError.message, - code: apiError.body?.code, + code: + apiError.body?.code ?? + (apiError.statusCode === 401 + ? "UNAUTHORIZED" + : apiError.statusCode === 403 + ? "FORBIDDEN" + : apiError.statusCode === 429 + ? "RATE_LIMITED" + : "AUTH_ERROR"), }, { status: apiError.statusCode }, ); } -export async function requireRequestSession(request: Request) { +function unauthorized() { + return { + ok: false as const, + response: Response.json( + { message: "Unauthorized", code: "UNAUTHORIZED" }, + { status: 401 }, + ), + }; +} + +function authProviderError(error: unknown) { + console.error("[public-api] authentication failed", error); + return { + ok: false as const, + response: Response.json( + { + message: "Authentication provider unavailable", + code: "AUTH_PROVIDER_ERROR", + }, + { status: 500 }, + ), + }; +} + +export async function requireRequestSession( + request: Request, +): Promise { let session: Awaited>; try { @@ -44,17 +90,11 @@ export async function requireRequestSession(request: Request) { }; } - throw error; + return authProviderError(error); } if (!session) { - return { - ok: false as const, - response: Response.json( - { message: "Unauthorized", code: "UNAUTHORIZED" }, - { status: 401 }, - ), - }; + return unauthorized(); } return { @@ -63,14 +103,73 @@ export async function requireRequestSession(request: Request) { }; } -export async function requireRequestRole( - request: Request, +/** Authenticate a canonical public API request using only X-API-Key. */ +export async function requireApiKeySession(request: Request) { + const apiKey = request.headers.get("x-api-key")?.trim(); + if (!apiKey) return unauthorized(); + + try { + const verification = await auth.api.verifyApiKey({ + body: { key: apiKey }, + }); + if (!verification.valid || !verification.key?.referenceId) { + return unauthorized(); + } + + const principal = await db + .select({ + id: user.id, + name: user.name, + email: user.email, + banned: user.banned, + banExpires: user.banExpires, + }) + .from(user) + .where(eq(user.id, verification.key.referenceId)) + .limit(1) + .then((rows) => rows[0]); + if ( + !principal || + (principal.banned && + (!principal.banExpires || principal.banExpires > new Date())) + ) { + return unauthorized(); + } + + return { + ok: true as const, + session: { + user: { + id: principal.id, + name: principal.name, + email: principal.email, + }, + session: { + id: verification.key.id, + expiresAt: + verification.key.expiresAt ?? + new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }, + }; + } catch (error) { + const response = getAuthErrorResponse(error); + if (response) { + return response.status >= 400 && + response.status < 500 && + response.status !== 429 + ? unauthorized() + : { ok: false as const, response }; + } + return authProviderError(error); + } +} + +async function requireSessionRole( + sessionResult: SessionResult, allowedRoles: MemberRole[], ) { - const sessionResult = await requireRequestSession(request); - if (!sessionResult.ok) { - return sessionResult; - } + if (!sessionResult.ok) return sessionResult; let role: MemberRole | null; try { @@ -86,7 +185,17 @@ export async function requireRequestRole( }; } - throw error; + console.error("[public-api] authorization lookup failed", error); + return { + ok: false as const, + response: Response.json( + { + message: "Authorization provider unavailable", + code: "AUTHORIZATION_ERROR", + }, + { status: 500 }, + ), + }; } if (!role || !hasAnyRole(role, allowedRoles)) { @@ -108,6 +217,24 @@ export async function requireRequestRole( }; } +export async function requireRequestRole( + request: Request, + allowedRoles: MemberRole[], +) { + return requireSessionRole(await requireRequestSession(request), allowedRoles); +} + export async function requireRequestDeveloperRole(request: Request) { return requireRequestRole(request, ["admin", "developer"]); } + +export async function requireApiKeyRole( + request: Request, + allowedRoles: MemberRole[], +) { + return requireSessionRole(await requireApiKeySession(request), allowedRoles); +} + +export async function requireApiKeyDeveloperRole(request: Request) { + return requireApiKeyRole(request, ["admin", "developer"]); +} diff --git a/web/lib/auth.ts b/web/lib/auth.ts index 8e38b566..b57f632e 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -32,7 +32,9 @@ export const auth = betterAuth({ validateClient: async (clientId) => clientId === TECHULUS_CLI_CLIENT_ID, }), apiKey({ - enableSessionForAPIKeys: true, + // Public API routes verify keys explicitly. Do not turn a key into a + // browser session, which would also authorize legacy dashboard APIs. + enableSessionForAPIKeys: false, apiKeyHeaders: "x-api-key", defaultPrefix: "tcl_", enableMetadata: true, diff --git a/web/lib/build-assignment.ts b/web/lib/build-assignment.ts index afbfe027..7239d1dc 100644 --- a/web/lib/build-assignment.ts +++ b/web/lib/build-assignment.ts @@ -1,7 +1,8 @@ -import { and, eq, gt, inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { db } from "@/db"; import { getSetting } from "@/db/queries"; -import { servers, serviceReplicas, services } from "@/db/schema"; +import { servers } from "@/db/schema"; +import type { ServiceRevisionSpec } from "@/lib/service-revision-spec"; import { SETTING_KEYS } from "@/lib/settings-keys"; type BuildTargetServer = { @@ -11,143 +12,110 @@ type BuildTargetServer = { }; async function getStatefulBuildTargetServer( - serviceId: string, + specification: ServiceRevisionSpec, ): Promise { - const targetServers = await db - .select({ id: servers.id, status: servers.status, meta: servers.meta }) - .from(serviceReplicas) - .innerJoin(servers, eq(serviceReplicas.serverId, servers.id)) - .where( - and( - eq(serviceReplicas.serviceId, serviceId), - gt(serviceReplicas.count, 0), - ), - ); - - if (targetServers.length !== 1) { + const placements = specification.placements.filter( + (placement) => placement.count > 0, + ); + if (placements.length !== 1 || placements[0].count !== 1) { throw new Error( - "Stateful services must have exactly one active replica server", + "Stateful service revisions must have exactly one active replica server", ); } - const [targetServer] = targetServers; - + const targetServer = await db + .select({ id: servers.id, status: servers.status, meta: servers.meta }) + .from(servers) + .where(eq(servers.id, placements[0].serverId)) + .then((rows) => rows[0]); + if (!targetServer) { + throw new Error("Stateful service revision target server was not found"); + } if (targetServer.status !== "online") { throw new Error("Stateful service target server is offline"); } - if (!targetServer.meta?.arch) { throw new Error("Stateful service target server architecture is unknown"); } - return { ...targetServer, meta: { arch: targetServer.meta.arch }, }; } -export async function selectBuildServerForPlatform( - serviceId: string, +export async function selectBuildServerForRevision( + specification: ServiceRevisionSpec, platform: string, ): Promise { - const service = await db - .select() - .from(services) - .where(eq(services.id, serviceId)) - .then((r) => r[0]); - - if (!service) { - throw new Error("Service not found"); - } - const arch = platform.split("/")[1]; + if (!arch) throw new Error(`Invalid build platform ${platform}`); - if (service.stateful) { - const targetServer = await getStatefulBuildTargetServer(serviceId); + if (specification.stateful) { + const targetServer = await getStatefulBuildTargetServer(specification); if (targetServer.meta.arch !== arch) { throw new Error( `Stateful service target server architecture ${targetServer.meta.arch} does not match platform ${platform}`, ); } - return targetServer.id; } const allowedBuildServerIds = await getSetting( SETTING_KEYS.SERVERS_ALLOWED_FOR_BUILDS, ); - - let onlineServers: { id: string; meta: { arch?: string } | null }[]; - - if (allowedBuildServerIds && allowedBuildServerIds.length > 0) { - onlineServers = await db - .select({ id: servers.id, meta: servers.meta }) - .from(servers) - .where( - and( - eq(servers.status, "online"), - inArray(servers.id, allowedBuildServerIds), - ), - ); - } else { - onlineServers = await db - .select({ id: servers.id, meta: servers.meta }) - .from(servers) - .where(eq(servers.status, "online")); - } - - const matchingServers = onlineServers.filter((s) => s.meta?.arch === arch); - + const onlineServers = allowedBuildServerIds?.length + ? await db + .select({ id: servers.id, meta: servers.meta }) + .from(servers) + .where( + and( + eq(servers.status, "online"), + inArray(servers.id, allowedBuildServerIds), + ), + ) + : await db + .select({ id: servers.id, meta: servers.meta }) + .from(servers) + .where(eq(servers.status, "online")); + const matchingServers = onlineServers.filter( + (server) => server.meta?.arch === arch, + ); if (matchingServers.length === 0) { throw new Error(`No online servers available for platform ${platform}`); } - return matchingServers[Math.floor(Math.random() * matchingServers.length)].id; } -export async function getTargetPlatformsForService( - serviceId: string, +export async function getTargetPlatformsForRevision( + specification: ServiceRevisionSpec, ): Promise { - const service = await db - .select() - .from(services) - .where(eq(services.id, serviceId)) - .then((r) => r[0]); - - if (!service) { - throw new Error("Service not found"); - } - - if (service.stateful) { - const targetServer = await getStatefulBuildTargetServer(serviceId); + if (specification.stateful) { + const targetServer = await getStatefulBuildTargetServer(specification); return [`linux/${targetServer.meta.arch}`]; } - let targetPlatforms: string[] = []; - - const replicas = await db - .select({ meta: servers.meta }) - .from(serviceReplicas) - .innerJoin(servers, eq(serviceReplicas.serverId, servers.id)) - .where( - and( - eq(serviceReplicas.serviceId, service.id), - gt(serviceReplicas.count, 0), - ), - ); - - targetPlatforms = [ + const serverIds = specification.placements + .filter((placement) => placement.count > 0) + .map((placement) => placement.serverId); + if (serverIds.length === 0) return ["linux/amd64", "linux/arm64"]; + + const placementServers = await db + .select({ id: servers.id, meta: servers.meta }) + .from(servers) + .where(inArray(servers.id, serverIds)); + if (placementServers.length !== new Set(serverIds).size) { + throw new Error("A service revision placement server was not found"); + } + const targetPlatforms = [ ...new Set( - replicas - .map((r) => r.meta?.arch) + placementServers + .map((server) => server.meta?.arch) .filter((arch): arch is string => !!arch) .map((arch) => `linux/${arch}`), ), ]; - if (targetPlatforms.length === 0) { - targetPlatforms.push("linux/amd64", "linux/arm64"); + throw new Error("Service revision placement architectures are unknown"); } - return targetPlatforms; } diff --git a/web/lib/build-revision-source.ts b/web/lib/build-revision-source.ts new file mode 100644 index 00000000..c6f35c09 --- /dev/null +++ b/web/lib/build-revision-source.ts @@ -0,0 +1,21 @@ +import { buildCloneUrl, getInstallationToken } from "@/lib/github"; +import type { ServiceRevisionSource } from "@/lib/service-revision-spec"; + +type GitHubRevisionSource = Extract; + +export function revisionRepositoryFullName(repository: string): string { + return new URL(repository).pathname.replace(/^\//, "").replace(/\.git$/i, ""); +} + +export async function cloneUrlForRevisionSource( + source: GitHubRevisionSource, + getToken: (installationId: number) => Promise = getInstallationToken, +): Promise { + if (source.authentication.type === "github_app") { + const token = await getToken(source.authentication.installationId); + return buildCloneUrl(token, revisionRepositoryFullName(source.repository)); + } + return source.repository.endsWith(".git") + ? source.repository + : `${source.repository}.git`; +} diff --git a/web/lib/cli-service.ts b/web/lib/cli-service.ts deleted file mode 100644 index c860240f..00000000 --- a/web/lib/cli-service.ts +++ /dev/null @@ -1,1037 +0,0 @@ -import { and, desc, eq, ne } from "drizzle-orm"; -import { - updateServiceConfig, - updateServiceResourceLimits, - updateServiceStartCommand, - validateDockerImage, -} from "@/actions/projects"; -import { db } from "@/db"; -import { - deployments, - environments, - projects, - rollouts, - servicePorts, - serviceReplicas, - services, - serviceVolumes, -} from "@/db/schema"; -import { - getManifestEnvironmentName, - getManifestProjectSlug, - getManifestServiceName, - type TechulusManifest, - techulusManifestSchema, -} from "@/lib/cli-manifest"; -import { deployServiceInternal } from "@/lib/deploy-service"; -import type { ServiceRevisionActor } from "@/lib/service-revision-actor"; -import { slugify } from "@/lib/utils"; - -export type ManifestChange = { - field: string; - from: string; - to: string; -}; - -function getManifestResourceLimits(manifest: TechulusManifest) { - const resources = manifest.service.resources; - if (resources === undefined) { - return null; - } - - return { - cpuCores: resources.cpuCores ?? null, - memoryMb: resources.memoryMb ?? null, - }; -} - -export type ManifestApplyResult = { - project: { id: string; name: string; slug: string }; - environment: { id: string; name: string }; - serviceId: string; - action: "created" | "updated" | "noop"; - changes: ManifestChange[]; -}; - -export type LinkServiceTarget = { - id: string; - name: string; - project: string; - environment: string; - linkSupported: boolean; - unsupportedReason: string | null; -}; - -export type LinkEnvironmentTarget = { - id: string; - name: string; - services: LinkServiceTarget[]; -}; - -export type LinkProjectTarget = { - id: string; - name: string; - slug: string; - environments: LinkEnvironmentTarget[]; -}; - -export type LinkTargetsResult = { - projects: LinkProjectTarget[]; -}; - -export type LinkManifestResult = { - manifest: TechulusManifest; - service: { - id: string; - name: string; - project: string; - environment: string; - }; -}; - -type ManifestIdentity = { - project: string; - environment: string; - service: string; -}; - -type ServiceCompatibilityRecord = Pick< - typeof services.$inferSelect, - "sourceType" | "stateful" | "resourceCpuLimit" | "resourceMemoryLimitMb" ->; - -type PortCompatibilityRecord = Pick< - typeof servicePorts.$inferSelect, - "protocol" | "isPublic" | "domain" ->; - -type LinkValidationService = Pick< - typeof services.$inferSelect, - | "id" - | "name" - | "projectId" - | "environmentId" - | "hostname" - | "image" - | "sourceType" - | "stateful" - | "healthCheckCmd" - | "healthCheckInterval" - | "healthCheckTimeout" - | "healthCheckRetries" - | "healthCheckStartPeriod" - | "startCommand" - | "resourceCpuLimit" - | "resourceMemoryLimitMb" ->; - -type LinkValidationPort = Pick< - typeof servicePorts.$inferSelect, - "serviceId" | "port" | "isPublic" | "domain" | "protocol" ->; - -type ServiceLinkValidation = { - service: LinkValidationService; - ports: LinkValidationPort[]; - placementReplicaCount: number; - unsupportedReason: string | null; -}; - -function formatPort(port: { - port: number; - isPublic: boolean; - domain: string | null; -}) { - return port.isPublic - ? `${port.port} -> ${port.domain}` - : `${port.port} (internal)`; -} - -function formatNullable( - value: string | number | null | undefined, - fallback = "(none)", -) { - if (value === null || value === undefined || value === "") { - return fallback; - } - - return String(value); -} - -function recordChange( - changes: ManifestChange[], - field: string, - from: string | number | null | undefined, - to: string | number | null | undefined, -) { - if ((from ?? null) === (to ?? null)) { - return; - } - - changes.push({ - field, - from: formatNullable(from), - to: formatNullable(to), - }); -} - -async function findProjectByManifest(manifest: TechulusManifest) { - const slug = getManifestProjectSlug(manifest); - return findProjectBySlug(slug); -} - -async function findProjectBySlug(slug: string) { - const [project] = await db - .select() - .from(projects) - .where(eq(projects.slug, slug)) - .limit(1); - - return project ?? null; -} - -async function findEnvironmentByManifest( - projectId: string, - manifest: TechulusManifest, -) { - const environmentName = getManifestEnvironmentName(manifest); - return findEnvironmentByName(projectId, environmentName); -} - -async function findEnvironmentByName( - projectId: string, - environmentName: string, -) { - const [environment] = await db - .select() - .from(environments) - .where( - and( - eq(environments.projectId, projectId), - eq(environments.name, environmentName), - ), - ) - .limit(1); - - return environment ?? null; -} - -async function findServiceByManifest( - projectId: string, - environmentId: string, - manifest: TechulusManifest, -) { - const serviceName = getManifestServiceName(manifest); - return findServiceByName(projectId, environmentId, serviceName); -} - -async function findServiceByName( - projectId: string, - environmentId: string, - serviceName: string, -) { - const [service] = await db - .select() - .from(services) - .where( - and( - eq(services.projectId, projectId), - eq(services.environmentId, environmentId), - eq(services.name, serviceName), - ), - ) - .limit(1); - - return service ?? null; -} - -function getUnsupportedReason( - service: ServiceCompatibilityRecord, - ports: PortCompatibilityRecord[], - volumeCount: number, - placementReplicaCount: number, -) { - if (service.sourceType !== "image") { - return "Techulus Cloud only supports image-backed services. This service uses an unsupported source."; - } - - if (service.stateful || volumeCount > 0) { - return "Techulus Cloud does not support stateful services or volumes. Manage this service from the web UI."; - } - - if (ports.some((port) => port.protocol !== "http")) { - return "Techulus Cloud only supports HTTP ports. This service has TCP or UDP ports configured."; - } - - if (ports.some((port) => port.isPublic && !port.domain)) { - return "Techulus Cloud requires every public HTTP port to have a domain."; - } - - if (placementReplicaCount < 1) { - return "Techulus Cloud requires manual server placement to be configured in the web UI before deploy."; - } - - if (placementReplicaCount > 10) { - return "Techulus Cloud only supports manually placed replica counts between 1 and 10."; - } - - const hasCpu = service.resourceCpuLimit !== null; - const hasMemory = service.resourceMemoryLimitMb !== null; - - if (hasCpu !== hasMemory) { - return "Techulus Cloud requires both CPU and memory limits to be set together."; - } - - return null; -} - -async function getServiceLinkValidation( - serviceId: string, -): Promise { - const [service] = await db - .select({ - id: services.id, - name: services.name, - projectId: services.projectId, - environmentId: services.environmentId, - hostname: services.hostname, - image: services.image, - sourceType: services.sourceType, - stateful: services.stateful, - healthCheckCmd: services.healthCheckCmd, - healthCheckInterval: services.healthCheckInterval, - healthCheckTimeout: services.healthCheckTimeout, - healthCheckRetries: services.healthCheckRetries, - healthCheckStartPeriod: services.healthCheckStartPeriod, - startCommand: services.startCommand, - resourceCpuLimit: services.resourceCpuLimit, - resourceMemoryLimitMb: services.resourceMemoryLimitMb, - }) - .from(services) - .where(eq(services.id, serviceId)) - .limit(1); - - if (!service) { - return null; - } - - const [ports, volumes, replicas] = await Promise.all([ - db - .select({ - serviceId: servicePorts.serviceId, - port: servicePorts.port, - isPublic: servicePorts.isPublic, - domain: servicePorts.domain, - protocol: servicePorts.protocol, - }) - .from(servicePorts) - .where(eq(servicePorts.serviceId, serviceId)) - .orderBy(servicePorts.port), - db - .select({ id: serviceVolumes.id }) - .from(serviceVolumes) - .where(eq(serviceVolumes.serviceId, serviceId)), - db - .select({ count: serviceReplicas.count }) - .from(serviceReplicas) - .where(eq(serviceReplicas.serviceId, serviceId)), - ]); - const placementReplicaCount = replicas.reduce( - (sum, replica) => sum + replica.count, - 0, - ); - - return { - service, - ports, - placementReplicaCount, - unsupportedReason: getUnsupportedReason( - service, - ports, - volumes.length, - placementReplicaCount, - ), - }; -} - -async function syncHostname( - serviceId: string, - currentHostname: string | null, - desiredHostname: string | null, - changes: ManifestChange[], -) { - if (currentHostname === desiredHostname) { - return; - } - - if (desiredHostname) { - const [existing] = await db - .select({ id: services.id }) - .from(services) - .where( - and(eq(services.hostname, desiredHostname), ne(services.id, serviceId)), - ) - .limit(1); - - if (existing) { - throw new Error("Hostname is already in use"); - } - } - - await db - .update(services) - .set({ hostname: desiredHostname }) - .where(eq(services.id, serviceId)); - - recordChange(changes, "Hostname", currentHostname, desiredHostname); -} - -async function syncImage( - serviceId: string, - currentImage: string, - desiredImage: string, - changes: ManifestChange[], -) { - if (currentImage === desiredImage) { - return; - } - - const validation = await validateDockerImage(desiredImage); - if (!validation.valid) { - throw new Error(validation.error || "Invalid image"); - } - - await updateServiceConfig(serviceId, { - source: { type: "image", image: desiredImage }, - }); - - recordChange(changes, "Image", currentImage, desiredImage); -} - -async function syncPorts( - serviceId: string, - desiredPorts: TechulusManifest["service"]["ports"], - changes: ManifestChange[], -) { - const currentPorts = await db - .select() - .from(servicePorts) - .where(eq(servicePorts.serviceId, serviceId)); - - const currentKeys = new Map( - currentPorts.map((port) => [ - `${port.port}:${port.isPublic ? "public" : "internal"}:${port.domain ?? ""}`, - port, - ]), - ); - - const desiredKeys = new Map( - desiredPorts.map((port) => [ - `${port.port}:${port.public ? "public" : "internal"}:${port.domain ?? ""}`, - port, - ]), - ); - - const portsToRemove = currentPorts - .filter( - (port) => - !desiredKeys.has( - `${port.port}:${port.isPublic ? "public" : "internal"}:${port.domain ?? ""}`, - ), - ) - .map((port) => port.id); - - const portsToAdd = desiredPorts - .filter( - (port) => - !currentKeys.has( - `${port.port}:${port.public ? "public" : "internal"}:${port.domain ?? ""}`, - ), - ) - .map((port) => ({ - port: port.port, - isPublic: port.public, - domain: port.public ? (port.domain ?? null) : null, - protocol: "http" as const, - })); - - if (portsToRemove.length === 0 && portsToAdd.length === 0) { - return; - } - - await updateServiceConfig(serviceId, { - ports: { - remove: portsToRemove, - add: portsToAdd, - }, - }); - - for (const port of currentPorts.filter((item) => - portsToRemove.includes(item.id), - )) { - changes.push({ - field: `Port ${port.port}`, - from: formatPort(port), - to: "(removed)", - }); - } - - for (const port of portsToAdd) { - changes.push({ - field: `Port ${port.port}`, - from: "(none)", - to: port.isPublic - ? `${port.port} -> ${port.domain}` - : `${port.port} (internal)`, - }); - } -} - -async function syncHealthCheck( - serviceId: string, - currentService: Pick< - LinkValidationService, - | "healthCheckCmd" - | "healthCheckInterval" - | "healthCheckTimeout" - | "healthCheckRetries" - | "healthCheckStartPeriod" - >, - manifest: TechulusManifest, - changes: ManifestChange[], -) { - const current = - currentService.healthCheckCmd === null - ? null - : { - cmd: currentService.healthCheckCmd, - interval: currentService.healthCheckInterval ?? 10, - timeout: currentService.healthCheckTimeout ?? 5, - retries: currentService.healthCheckRetries ?? 3, - startPeriod: currentService.healthCheckStartPeriod ?? 30, - }; - - const desired = manifest.service.healthCheck ?? null; - - if (JSON.stringify(current) === JSON.stringify(desired)) { - return; - } - - await updateServiceConfig(serviceId, { - healthCheck: desired, - }); - - recordChange( - changes, - "Health check", - current?.cmd ?? null, - desired?.cmd ?? null, - ); -} - -async function syncStartCommand( - serviceId: string, - currentStartCommand: string | null, - desiredStartCommand: string | null, - changes: ManifestChange[], -) { - if (currentStartCommand === desiredStartCommand) { - return; - } - - await updateServiceStartCommand(serviceId, desiredStartCommand); - recordChange( - changes, - "Start command", - currentStartCommand, - desiredStartCommand, - ); -} - -async function syncResources( - serviceId: string, - currentService: Pick< - LinkValidationService, - "resourceCpuLimit" | "resourceMemoryLimitMb" - >, - manifest: TechulusManifest, - changes: ManifestChange[], -) { - const desiredResources = getManifestResourceLimits(manifest); - if (desiredResources === null) { - return; - } - const desiredCpu = desiredResources.cpuCores; - const desiredMemory = desiredResources.memoryMb; - - if ( - currentService.resourceCpuLimit === desiredCpu && - currentService.resourceMemoryLimitMb === desiredMemory - ) { - return; - } - - await updateServiceResourceLimits(serviceId, { - cpuCores: desiredCpu, - memoryMb: desiredMemory, - }); - - recordChange( - changes, - "CPU limit", - currentService.resourceCpuLimit, - desiredCpu, - ); - recordChange( - changes, - "Memory limit", - currentService.resourceMemoryLimitMb, - desiredMemory, - ); -} - -async function assertSupportedExistingService(serviceId: string) { - const validation = await getServiceLinkValidation(serviceId); - if (!validation) { - throw new Error("Service not found"); - } - - if (validation.unsupportedReason) { - throw new Error(validation.unsupportedReason); - } - - return validation; -} - -function assertManifestReplicaCount( - placementReplicaCount: number, - desiredReplicaCount: number, -) { - if (placementReplicaCount !== desiredReplicaCount) { - throw new Error( - `Techulus Cloud cannot change server placement. Update placement in the web UI so it has ${desiredReplicaCount} replica${desiredReplicaCount === 1 ? "" : "s"}, or update replicas.count to match the current manual placement of ${placementReplicaCount}.`, - ); - } -} - -export async function applyManifest( - manifest: TechulusManifest, -): Promise { - const project = await findProjectByManifest(manifest); - if (!project) { - throw new Error( - "Project not found. Create the service and select server placement in the web UI before using Techulus Cloud.", - ); - } - - const environment = await findEnvironmentByManifest(project.id, manifest); - if (!environment) { - throw new Error( - "Environment not found. Create the service and select server placement in the web UI before using Techulus Cloud.", - ); - } - - const service = await findServiceByManifest( - project.id, - environment.id, - manifest, - ); - const changes: ManifestChange[] = []; - - if (!service) { - throw new Error( - "Techulus Cloud cannot create services because server placement must be selected in the web UI.", - ); - } - - const validation = await assertSupportedExistingService(service.id); - const currentService = validation.service; - assertManifestReplicaCount( - validation.placementReplicaCount, - manifest.service.replicas.count, - ); - - await syncHostname( - service.id, - currentService.hostname, - manifest.service.hostname ?? null, - changes, - ); - await syncImage( - service.id, - currentService.image, - manifest.service.source.image, - changes, - ); - await syncPorts(service.id, manifest.service.ports, changes); - await syncHealthCheck(service.id, currentService, manifest, changes); - await syncStartCommand( - service.id, - currentService.startCommand, - manifest.service.startCommand ?? null, - changes, - ); - await syncResources(service.id, currentService, manifest, changes); - const refreshedProject = await findProjectByManifest(manifest); - const refreshedEnvironment = await findEnvironmentByManifest( - project.id, - manifest, - ); - - if (!refreshedProject || !refreshedEnvironment) { - throw new Error("Failed to reload manifest resources after apply"); - } - - return { - project: refreshedProject, - environment: refreshedEnvironment, - serviceId: service.id, - action: changes.length === 0 ? "noop" : "updated", - changes, - }; -} - -export async function deployManifest( - manifest: TechulusManifest, - actor: ServiceRevisionActor | null, -) { - const project = await findProjectByManifest(manifest); - if (!project) { - throw new Error("Project not found"); - } - - const environment = await findEnvironmentByManifest(project.id, manifest); - if (!environment) { - throw new Error("Environment not found"); - } - - const service = await findServiceByManifest( - project.id, - environment.id, - manifest, - ); - if (!service) { - throw new Error("Service not found"); - } - - const validation = await assertSupportedExistingService(service.id); - assertManifestReplicaCount( - validation.placementReplicaCount, - manifest.service.replicas.count, - ); - - const result = await deployServiceInternal(service.id, actor); - - return { - serviceId: service.id, - rolloutId: "rolloutId" in result ? result.rolloutId : null, - status: "migrationStarted" in result ? "migration_started" : "queued", - }; -} - -export async function getManifestStatus(identity: ManifestIdentity) { - const project = await findProjectBySlug(identity.project); - if (!project) { - return null; - } - - const environment = await findEnvironmentByName( - project.id, - slugify(identity.environment), - ); - if (!environment) { - return null; - } - - const service = await findServiceByName( - project.id, - environment.id, - identity.service.trim(), - ); - if (!service) { - return null; - } - - const [latestRollout] = await db - .select({ - id: rollouts.id, - status: rollouts.status, - currentStage: rollouts.currentStage, - createdAt: rollouts.createdAt, - }) - .from(rollouts) - .where(eq(rollouts.serviceId, service.id)) - .orderBy(desc(rollouts.createdAt)) - .limit(1); - - const serviceDeployments = await db - .select({ - id: deployments.id, - status: deployments.observedPhase, - serverId: deployments.serverId, - createdAt: deployments.createdAt, - }) - .from(deployments) - .where(eq(deployments.serviceId, service.id)) - .orderBy(desc(deployments.createdAt)); - - const ports = await db - .select({ - id: servicePorts.id, - port: servicePorts.port, - isPublic: servicePorts.isPublic, - domain: servicePorts.domain, - protocol: servicePorts.protocol, - }) - .from(servicePorts) - .where(eq(servicePorts.serviceId, service.id)); - const replicas = await db - .select({ count: serviceReplicas.count }) - .from(serviceReplicas) - .where(eq(serviceReplicas.serviceId, service.id)); - const placementReplicaCount = replicas.reduce( - (sum, replica) => sum + replica.count, - 0, - ); - - return { - service: { - id: service.id, - name: service.name, - image: service.image, - hostname: service.hostname, - replicas: placementReplicaCount, - sourceType: service.sourceType, - }, - ports, - latestRollout: latestRollout ?? null, - deployments: serviceDeployments, - }; -} - -export async function listLinkTargets(): Promise { - const [ - projectRows, - environmentRows, - serviceRows, - portRows, - volumeRows, - replicaRows, - ] = await Promise.all([ - db - .select({ - id: projects.id, - name: projects.name, - slug: projects.slug, - }) - .from(projects) - .orderBy(projects.createdAt), - db - .select({ - id: environments.id, - projectId: environments.projectId, - name: environments.name, - }) - .from(environments) - .orderBy(environments.createdAt), - db - .select({ - id: services.id, - name: services.name, - projectId: services.projectId, - environmentId: services.environmentId, - sourceType: services.sourceType, - stateful: services.stateful, - resourceCpuLimit: services.resourceCpuLimit, - resourceMemoryLimitMb: services.resourceMemoryLimitMb, - }) - .from(services) - .orderBy(services.createdAt), - db - .select({ - serviceId: servicePorts.serviceId, - protocol: servicePorts.protocol, - isPublic: servicePorts.isPublic, - domain: servicePorts.domain, - }) - .from(servicePorts), - db - .select({ serviceId: serviceVolumes.serviceId }) - .from(serviceVolumes) - .orderBy(serviceVolumes.id), - db - .select({ - serviceId: serviceReplicas.serviceId, - count: serviceReplicas.count, - }) - .from(serviceReplicas) - .orderBy(serviceReplicas.id), - ]); - - const projectNameById = new Map( - projectRows.map((project) => [project.id, project.name]), - ); - const environmentById = new Map( - environmentRows.map((environment) => [environment.id, environment]), - ); - - const portsByServiceId = new Map(); - for (const port of portRows) { - const current = portsByServiceId.get(port.serviceId) ?? []; - current.push(port); - portsByServiceId.set(port.serviceId, current); - } - - const volumeCountByServiceId = new Map(); - for (const volume of volumeRows) { - volumeCountByServiceId.set( - volume.serviceId, - (volumeCountByServiceId.get(volume.serviceId) ?? 0) + 1, - ); - } - const replicaCountByServiceId = new Map(); - for (const replica of replicaRows) { - replicaCountByServiceId.set( - replica.serviceId, - (replicaCountByServiceId.get(replica.serviceId) ?? 0) + replica.count, - ); - } - - const servicesByEnvironmentId = new Map(); - for (const service of serviceRows) { - const projectName = projectNameById.get(service.projectId); - const environment = environmentById.get(service.environmentId); - if (!projectName || !environment) { - continue; - } - - const current = servicesByEnvironmentId.get(service.environmentId) ?? []; - const ports = portsByServiceId.get(service.id) ?? []; - const unsupportedReason = getUnsupportedReason( - service, - ports, - volumeCountByServiceId.get(service.id) ?? 0, - replicaCountByServiceId.get(service.id) ?? 0, - ); - - current.push({ - id: service.id, - name: service.name, - project: projectName, - environment: environment.name, - linkSupported: unsupportedReason === null, - unsupportedReason, - }); - servicesByEnvironmentId.set(service.environmentId, current); - } - - const environmentsByProjectId = new Map(); - for (const environment of environmentRows) { - const current = environmentsByProjectId.get(environment.projectId) ?? []; - current.push({ - id: environment.id, - name: environment.name, - services: servicesByEnvironmentId.get(environment.id) ?? [], - }); - environmentsByProjectId.set(environment.projectId, current); - } - - return { - projects: projectRows.map((project) => ({ - id: project.id, - name: project.name, - slug: project.slug, - environments: environmentsByProjectId.get(project.id) ?? [], - })), - }; -} - -export async function exportManifestForLinkedService( - serviceId: string, -): Promise { - const validation = await getServiceLinkValidation(serviceId); - if (!validation) { - throw new Error("Service not found"); - } - - if (validation.unsupportedReason) { - throw new Error(validation.unsupportedReason); - } - - const [project, environment] = await Promise.all([ - db - .select() - .from(projects) - .where(eq(projects.id, validation.service.projectId)) - .limit(1), - db - .select() - .from(environments) - .where(eq(environments.id, validation.service.environmentId)) - .limit(1), - ]); - - const projectRow = project[0]; - const environmentRow = environment[0]; - - if (!projectRow || !environmentRow) { - throw new Error("Failed to resolve the selected service"); - } - - const manifest = techulusManifestSchema.parse({ - apiVersion: "v1", - project: projectRow.name, - environment: environmentRow.name, - service: { - name: validation.service.name, - source: { - type: "image", - image: validation.service.image, - }, - ...(validation.service.hostname - ? { hostname: validation.service.hostname } - : {}), - ports: validation.ports.map((port) => ({ - port: port.port, - public: port.isPublic, - ...(port.isPublic && port.domain ? { domain: port.domain } : {}), - })), - replicas: { - count: validation.placementReplicaCount, - }, - ...(validation.service.healthCheckCmd - ? { - healthCheck: { - cmd: validation.service.healthCheckCmd, - interval: validation.service.healthCheckInterval ?? 10, - timeout: validation.service.healthCheckTimeout ?? 5, - retries: validation.service.healthCheckRetries ?? 3, - startPeriod: validation.service.healthCheckStartPeriod ?? 30, - }, - } - : {}), - ...(validation.service.startCommand - ? { startCommand: validation.service.startCommand } - : {}), - ...(validation.service.resourceCpuLimit !== null && - validation.service.resourceMemoryLimitMb !== null - ? { - resources: { - cpuCores: validation.service.resourceCpuLimit, - memoryMb: validation.service.resourceMemoryLimitMb, - }, - } - : {}), - }, - }); - - return { - manifest, - service: { - id: validation.service.id, - name: validation.service.name, - project: projectRow.name, - environment: environmentRow.name, - }, - }; -} diff --git a/web/lib/deploy-service.ts b/web/lib/deploy-service.ts index 41f430f4..e7825137 100644 --- a/web/lib/deploy-service.ts +++ b/web/lib/deploy-service.ts @@ -7,11 +7,46 @@ import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; import { startMigrationInternal } from "@/lib/migrations"; import type { ServiceRevisionActor } from "@/lib/service-revision-actor"; -import { createRolloutWithServiceRevision } from "@/lib/service-revisions"; +import { + createRolloutForServiceRevision, + createRolloutWithServiceRevision, +} from "@/lib/service-revisions"; +import { triggerBuildInternal } from "@/lib/trigger-build"; + +export async function deployServiceRevisionInternal( + serviceId: string, + serviceRevisionId: string, + artifactImageUri: string, +) { + const result = await createRolloutForServiceRevision( + serviceId, + serviceRevisionId, + artifactImageUri, + ); + if (!result.rolloutId) return result; + + await inngest.send( + inngestEvents.rolloutCreated.create( + { + rolloutId: result.rolloutId, + serviceId, + }, + { + id: `rollout-created-${result.rolloutId}`, + }, + ), + ); + + return result; +} export async function deployServiceInternal( serviceId: string, actor: ServiceRevisionActor | null, + options: { + runtimeBaseRevisionId?: string; + githubTrigger?: "manual" | "scheduled"; + } = {}, ) { const service = await getService(serviceId); if (!service) { @@ -51,10 +86,19 @@ export async function deployServiceInternal( return { migrationStarted: true }; } } + const runtimeBaseRevisionId = + service.sourceType === "github" ? options.runtimeBaseRevisionId : undefined; + if (service.sourceType === "github" && !runtimeBaseRevisionId) { + if (!options.githubTrigger || !actor) { + throw new Error("GitHub deployment requires a build trigger"); + } + return triggerBuildInternal(serviceId, options.githubTrigger, actor); + } const { rolloutId } = await createRolloutWithServiceRevision( serviceId, actor, + runtimeBaseRevisionId, ); try { diff --git a/web/lib/docker-image.ts b/web/lib/docker-image.ts index 2bf3dfbc..0fc90305 100644 --- a/web/lib/docker-image.ts +++ b/web/lib/docker-image.ts @@ -16,3 +16,250 @@ export function imageIsUnqualified(image: string): boolean { export function imageNeedsProductionPinning(image: string): boolean { return image !== "" && imageUsesMutableReference(image); } + +const DOCKER_AUTH_URL = "https://auth.docker.io/token"; +const DOCKER_MANIFEST_BASE = "https://registry-1.docker.io/v2"; +const DOCKER_TAGS_BASE = "https://hub.docker.com/v2/repositories"; +const GHCR_TOKEN_URL = "https://ghcr.io/token"; +const GHCR_MANIFEST_BASE = "https://ghcr.io/v2"; +const MANIFEST_ACCEPT = + "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; + +function isValidImageReferencePart(reference: string): boolean { + const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; + const digestPattern = /^[A-Za-z0-9_+.-]+:[0-9a-fA-F]{32,256}$/; + + return ( + reference === "latest" || + tagPattern.test(reference) || + digestPattern.test(reference) + ); +} + +function isValidImageNamePart(part: string): boolean { + const segmentPattern = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; + return part.split("/").every((segment) => segmentPattern.test(segment)); +} + +function isValidRegistry(registry: string): boolean { + if (!/^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(?::[0-9]+)?$/.test(registry)) { + return false; + } + try { + const url = new URL(`https://${registry}`); + return ( + url.protocol === "https:" && + url.username === "" && + url.password === "" && + url.pathname === "/" && + url.search === "" && + url.hash === "" && + url.hostname !== "" + ); + } catch { + return false; + } +} + +function encodePathSegments(value: string): string { + return value + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function getBearerToken(value: unknown): string | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + for (const candidate of [record.token, record.access_token]) { + if ( + typeof candidate === "string" && + candidate.trim() === candidate && + candidate.length > 0 && + !candidate.includes("\r") && + !candidate.includes("\n") + ) { + return candidate; + } + } + return null; +} + +async function readBearerToken(response: Response): Promise { + try { + const value: unknown = await response.json(); + return getBearerToken(value); + } catch { + return null; + } +} + +function parseImageReference(image: string): { + registry: string; + repositoryPath: string; + tag: string | null; + digest: string | null; +} { + let registry = "docker.io"; + let tag: string | null = null; + let digest: string | null = null; + let imagePath = image; + + const digestIndex = imagePath.indexOf("@"); + if (digestIndex !== -1) { + digest = imagePath.substring(digestIndex + 1); + imagePath = imagePath.substring(0, digestIndex); + } + const tagIndex = imagePath.lastIndexOf(":"); + if (tagIndex > imagePath.lastIndexOf("/")) { + tag = imagePath.substring(tagIndex + 1); + imagePath = imagePath.substring(0, tagIndex); + } else if (!digest) { + tag = "latest"; + } + + const parts = imagePath.split("/"); + const first = parts[0] ?? ""; + const hasExplicitRegistry = + parts.length > 1 && + (first.toLowerCase() === "localhost" || + first.includes(".") || + first.includes(":") || + first.toLowerCase() !== first); + const nameParts = hasExplicitRegistry ? parts.slice(1) : parts; + if (hasExplicitRegistry) { + registry = + first.toLowerCase() === "index.docker.io" + ? "docker.io" + : first.toLowerCase(); + } + let repositoryPath = nameParts.join("/"); + if (registry === "docker.io" && nameParts.length === 1) { + repositoryPath = `library/${repositoryPath}`; + } + + return { registry, repositoryPath, tag, digest }; +} + +export async function validateDockerImageInternal( + image: string, +): Promise<{ valid: boolean; error?: string }> { + try { + const { registry, repositoryPath, tag, digest } = + parseImageReference(image); + const reference = digest || tag || "latest"; + + if ( + !isValidImageReferencePart(reference) || + (tag !== null && !isValidImageReferencePart(tag)) + ) { + return { valid: false, error: "Invalid image tag or digest" }; + } + if (!isValidRegistry(registry) || !isValidImageNamePart(repositoryPath)) { + return { valid: false, error: "Invalid image name" }; + } + const encodedRepositoryPath = encodePathSegments(repositoryPath); + const encodedReference = encodeURIComponent(reference); + + if (registry === "docker.io") { + if (digest) { + const tokenUrl = new URL(DOCKER_AUTH_URL); + tokenUrl.search = new URLSearchParams({ + service: "registry.docker.io", + scope: `repository:${repositoryPath}:pull`, + }).toString(); + const tokenResponse = await fetch(tokenUrl, { redirect: "error" }); + if (!tokenResponse.ok) { + return { + valid: false, + error: "Failed to authenticate with Docker Hub", + }; + } + const token = await readBearerToken(tokenResponse); + if (!token) { + return { + valid: false, + error: "Failed to authenticate with Docker Hub", + }; + } + const manifestUrl = `${DOCKER_MANIFEST_BASE}/${encodedRepositoryPath}/manifests/${encodedReference}`; + const manifestResponse = await fetch(manifestUrl, { + redirect: "error", + headers: { + Authorization: `Bearer ${token}`, + Accept: MANIFEST_ACCEPT, + }, + }); + if (manifestResponse.status === 404) { + return { + valid: false, + error: "Image digest not found on Docker Hub", + }; + } + if (!manifestResponse.ok) { + return { valid: false, error: "Failed to validate image" }; + } + return { valid: true }; + } + + const url = `${DOCKER_TAGS_BASE}/${encodedRepositoryPath}/tags/${encodedReference}`; + const response = await fetch(url, { + method: "GET", + redirect: "error", + }); + if (response.status === 404) { + return { valid: false, error: "Image or tag not found on Docker Hub" }; + } + if (!response.ok) { + return { valid: false, error: "Failed to validate image" }; + } + return { valid: true }; + } + + if (registry === "ghcr.io") { + const tokenUrl = new URL(GHCR_TOKEN_URL); + tokenUrl.search = new URLSearchParams({ + scope: `repository:${repositoryPath}:pull`, + }).toString(); + const tokenResponse = await fetch(tokenUrl, { redirect: "error" }); + if (!tokenResponse.ok) { + return { + valid: false, + error: "Image not found on GitHub Container Registry", + }; + } + const token = await readBearerToken(tokenResponse); + if (!token) { + return { + valid: false, + error: "Image not found on GitHub Container Registry", + }; + } + const manifestUrl = `${GHCR_MANIFEST_BASE}/${encodedRepositoryPath}/manifests/${encodedReference}`; + const manifestResponse = await fetch(manifestUrl, { + redirect: "error", + headers: { + Authorization: `Bearer ${token}`, + Accept: MANIFEST_ACCEPT, + }, + }); + if (manifestResponse.status === 404) { + return { + valid: false, + error: `Image ${digest ? "digest" : "tag"} not found on GitHub Container Registry`, + }; + } + if (!manifestResponse.ok) { + return { valid: false, error: "Failed to validate image" }; + } + return { valid: true }; + } + + return { valid: true }; + } catch (error) { + console.error("Image validation error:", error); + return { valid: false, error: "Failed to validate image" }; + } +} diff --git a/web/lib/github.ts b/web/lib/github.ts index 946ce923..7ee0145f 100644 --- a/web/lib/github.ts +++ b/web/lib/github.ts @@ -187,6 +187,54 @@ async function githubCommitRequest( return response.json() as Promise; } +async function publicGitHubCommitRequest( + repoFullName: string, + suffix: string, +): Promise { + validateRepoFullName(repoFullName); + const response = await fetch( + `https://api.github.com/repos/${repoFullName}/commits${suffix}`, + { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `Public GitHub commit request failed (${response.status}): ${detail || response.statusText}`, + ); + } + return response.json() as Promise; +} + +export async function resolveGitHubCommit( + repoFullName: string, + branch: string, + installationId?: number, +): Promise { + const ref = branch.trim(); + if (!ref) throw new Error("GitHub branch is not configured"); + const suffix = `?sha=${encodeURIComponent(ref)}&per_page=1`; + const commits = installationId + ? await githubCommitRequest( + installationId, + repoFullName, + suffix, + ) + : await publicGitHubCommitRequest( + repoFullName, + suffix, + ); + const commit = commits[0]; + if (!commit || !isFullCommitSha(commit.sha)) { + throw new Error("GitHub branch did not resolve to an exact commit"); + } + return mapGitHubCommit(commit); +} + export async function listGitHubCommits( installationId: number, repoFullName: string, diff --git a/web/lib/inngest/events/build.ts b/web/lib/inngest/events/build.ts index 6ac67d78..1f8214fa 100644 --- a/web/lib/inngest/events/build.ts +++ b/web/lib/inngest/events/build.ts @@ -4,8 +4,9 @@ export type BuildEvents = { "build/trigger": { data: { serviceId: string; + serviceRevisionId: string; + buildRequestId: string; trigger: "manual" | "scheduled" | "push"; - githubRepoId?: string; commitSha: string; commitMessage: string; branch: string; @@ -18,21 +19,23 @@ export type BuildEvents = { data: { buildId: string; serviceId: string; - buildGroupId: string | null; + serviceRevisionId: string; + buildGroupId: string; actor?: ServiceRevisionActor | null; }; }; "build/cancelled": { data: { buildId: string; - buildGroupId: string | null; + buildGroupId: string; }; }; "build/completed": { data: { buildId: string; serviceId: string; - buildGroupId: string | null; + serviceRevisionId: string; + buildGroupId: string; status: "success" | "failed"; imageUri?: string; error?: string; @@ -41,6 +44,7 @@ export type BuildEvents = { "manifest/completed": { data: { serviceId: string; + serviceRevisionId: string; buildGroupId: string; imageUri: string; }; @@ -48,6 +52,7 @@ export type BuildEvents = { "manifest/failed": { data: { serviceId: string; + serviceRevisionId: string; buildGroupId: string; error: string; }; diff --git a/web/lib/inngest/events/migration.ts b/web/lib/inngest/events/migration.ts index 3b8a6dd6..79e29485 100644 --- a/web/lib/inngest/events/migration.ts +++ b/web/lib/inngest/events/migration.ts @@ -7,6 +7,7 @@ export type MigrationEvents = { targetServerId: string; sourceServerId: string; sourceDeploymentId: string; + sourceServiceRevisionId: string; sourceContainerId: string; volumes: { id: string; name: string }[]; actor?: ServiceRevisionActor | null; diff --git a/web/lib/inngest/functions/build-trigger-workflow.ts b/web/lib/inngest/functions/build-trigger-workflow.ts index 99e4732b..acc3383f 100644 --- a/web/lib/inngest/functions/build-trigger-workflow.ts +++ b/web/lib/inngest/functions/build-trigger-workflow.ts @@ -1,13 +1,23 @@ -import { randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; import { db } from "@/db"; -import { builds } from "@/db/schema"; -import { inngest } from "../client"; -import { inngestEvents } from "../events"; +import { builds, serviceRevisions } from "@/db/schema"; import { - selectBuildServerForPlatform, - getTargetPlatformsForService, + getTargetPlatformsForRevision, + selectBuildServerForRevision, } from "@/lib/build-assignment"; +import { isFullCommitSha } from "@/lib/github"; +import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; import { enqueueWork } from "@/lib/work-queue"; +import { inngest } from "../client"; +import { inngestEvents } from "../events"; + +function buildIdForRequest(buildRequestId: string, platform: string): string { + const hash = createHash("sha256") + .update(`${buildRequestId}:${platform}`) + .digest("hex"); + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-5${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`; +} export const buildTriggerWorkflow = inngest.createFunction( { @@ -18,7 +28,8 @@ export const buildTriggerWorkflow = inngest.createFunction( async ({ event, step }) => { const { serviceId, - githubRepoId, + serviceRevisionId, + buildRequestId, commitSha, commitMessage, branch, @@ -26,51 +37,143 @@ export const buildTriggerWorkflow = inngest.createFunction( githubDeploymentId, actor = null, } = event.data; + if (!isFullCommitSha(commitSha)) { + throw new Error("Build fan-out requires a full 40-character commit SHA"); + } + const exactCommitSha = commitSha.toLowerCase(); + const specification = await step.run("get-build-revision", async () => { + const revision = await db + .select({ specification: serviceRevisions.specification }) + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.id, serviceRevisionId), + eq(serviceRevisions.serviceId, serviceId), + ), + ) + .then((rows) => rows[0]); + if (!revision) throw new Error("Build service revision not found"); + const parsed = parseServiceRevisionSpec(revision.specification); + if ( + parsed.source.type !== "github" || + parsed.source.commitSha !== exactCommitSha || + parsed.source.branch !== branch + ) { + throw new Error("Build trigger does not match its service revision"); + } + return parsed; + }); const { buildIds, buildGroupId } = await step.run( "create-builds", async () => { - const targetPlatforms = await getTargetPlatformsForService(serviceId); - const groupId = randomUUID(); - const ids: string[] = []; + const targetPlatforms = + await getTargetPlatformsForRevision(specification); + if (targetPlatforms.length === 0) { + throw new Error("No target platforms configured for this build"); + } + if (new Set(targetPlatforms).size !== targetPlatforms.length) { + throw new Error( + "Duplicate target platforms configured for this build", + ); + } - for (const platform of targetPlatforms) { - const buildId = randomUUID(); - ids.push(buildId); + const assignments = await Promise.all( + targetPlatforms.map(async (platform) => ({ + id: buildIdForRequest(buildRequestId, platform), + platform, + serverId: await selectBuildServerForRevision( + specification, + platform, + ), + })), + ); + const buildRows = assignments.map(({ id, platform }) => ({ + id, + serviceId, + serviceRevisionId, + commitSha: exactCommitSha, + commitMessage, + branch, + author, + targetPlatform: platform, + buildGroupId: buildRequestId, + status: "pending" as const, + githubDeploymentId, + })); + const inserted = await db + .insert(builds) + .values(buildRows) + .onConflictDoNothing({ target: builds.id }) + .returning({ id: builds.id }); - await db.insert(builds).values({ - id: buildId, - githubRepoId: githubRepoId ?? null, - serviceId, - commitSha, - commitMessage, - branch, - author, - targetPlatform: platform, - buildGroupId: groupId, - status: "pending", - githubDeploymentId, - }); + if (inserted.length !== buildRows.length) { + const existingRows = await db + .select({ + id: builds.id, + serviceId: builds.serviceId, + serviceRevisionId: builds.serviceRevisionId, + commitSha: builds.commitSha, + branch: builds.branch, + targetPlatform: builds.targetPlatform, + buildGroupId: builds.buildGroupId, + }) + .from(builds) + .where( + inArray( + builds.id, + buildRows.map((row) => row.id), + ), + ); + const existingById = new Map( + existingRows.map((row) => [row.id, row]), + ); + for (const expected of buildRows) { + const existing = existingById.get(expected.id); + if ( + !existing || + existing.serviceId !== expected.serviceId || + existing.serviceRevisionId !== expected.serviceRevisionId || + existing.commitSha !== expected.commitSha || + existing.branch !== expected.branch || + existing.targetPlatform !== expected.targetPlatform || + existing.buildGroupId !== expected.buildGroupId + ) { + throw new Error("Build request idempotency conflict"); + } + } + } - const serverId = await selectBuildServerForPlatform( - serviceId, - platform, + for (const assignment of assignments) { + await enqueueWork( + assignment.serverId, + "build", + { buildId: assignment.id }, + { id: `build-work-${assignment.id}` }, ); - await enqueueWork(serverId, "build", { buildId }); } - return { buildIds: ids, buildGroupId: groupId }; + return { + buildIds: assignments.map((assignment) => assignment.id), + buildGroupId: buildRequestId, + }; }, ); await step.run("send-build-started", async () => { await inngest.send( - inngestEvents.buildStarted.create({ - buildId: buildIds[0], - serviceId, - buildGroupId, - actor, - }), + inngestEvents.buildStarted.create( + { + buildId: buildIds[0], + serviceId, + serviceRevisionId, + buildGroupId, + actor, + }, + { + id: `build-started-${buildRequestId}`, + }, + ), ); }); diff --git a/web/lib/inngest/functions/build-workflow.ts b/web/lib/inngest/functions/build-workflow.ts index bda6f49f..7b34d478 100644 --- a/web/lib/inngest/functions/build-workflow.ts +++ b/web/lib/inngest/functions/build-workflow.ts @@ -1,44 +1,158 @@ -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { db } from "@/db"; -import { builds, serviceReplicas, services, workQueue } from "@/db/schema"; -import { deployServiceInternal } from "@/lib/deploy-service"; +import { builds, workQueue } from "@/db/schema"; +import { deployServiceRevisionInternal } from "@/lib/deploy-service"; import { inngest } from "../client"; import { inngestEvents } from "../events"; -async function hasCompletedManifestWorkItem({ +type BuildStatus = typeof builds.$inferSelect.status; +type GroupBuild = { + id: string; + status: BuildStatus; + targetPlatform: string; + imageUri: string | null; +}; +type ManifestState = + | { + status: "completed"; + finalImageUri: string; + images: string[]; + } + | { status: "failed" } + | null; + +const nonTerminalBuildStatuses: BuildStatus[] = [ + "pending", + "claimed", + "cloning", + "building", + "pushing", +]; + +function platformImageForTarget(finalImage: string, targetPlatform: string) { + const [operatingSystem, architecture, ...extra] = targetPlatform.split("/"); + if ( + operatingSystem !== "linux" || + !architecture || + extra.length > 0 || + !["amd64", "arm64"].includes(architecture) + ) { + throw new Error(`Invalid build target platform: ${targetPlatform}`); + } + return `${finalImage}-${architecture}`; +} + +async function getGroupBuilds( + serviceId: string, + serviceRevisionId: string, + buildGroupId: string, +) { + return db + .select({ + id: builds.id, + status: builds.status, + targetPlatform: builds.targetPlatform, + imageUri: builds.imageUri, + }) + .from(builds) + .where( + and( + eq(builds.serviceId, serviceId), + eq(builds.serviceRevisionId, serviceRevisionId), + eq(builds.buildGroupId, buildGroupId), + ), + ); +} + +function groupFailure(groupBuilds: GroupBuild[]) { + return groupBuilds.some((build) => + ["failed", "cancelled"].includes(build.status), + ); +} + +async function manifestState({ serviceId, - buildId, + serviceRevisionId, buildGroupId, }: { serviceId: string; - buildId?: string; - buildGroupId?: string | null; -}) { - const completedManifestItems = await db - .select({ payload: workQueue.payload }) + serviceRevisionId: string; + buildGroupId: string; +}): Promise { + const item = await db + .select({ status: workQueue.status, payload: workQueue.payload }) .from(workQueue) .where( and( + eq(workQueue.id, `manifest-work-${buildGroupId}`), eq(workQueue.type, "create_manifest"), - eq(workQueue.status, "completed"), ), - ); + ) + .then((rows) => rows[0]); + if (!item || !["completed", "failed"].includes(item.status)) return null; - return completedManifestItems.some((item) => { - try { - const payload = JSON.parse(item.payload) as { - serviceId?: string; - buildId?: string; - buildGroupId?: string; - }; + let payload: { + serviceId?: string; + serviceRevisionId?: string; + buildGroupId?: string; + finalImageUri?: string; + images?: unknown; + }; + try { + payload = JSON.parse(item.payload); + } catch { + throw new Error("Build manifest work item has an invalid payload"); + } + if ( + payload.serviceId !== serviceId || + payload.serviceRevisionId !== serviceRevisionId || + payload.buildGroupId !== buildGroupId + ) { + throw new Error("Build manifest work item identity does not match"); + } + if (item.status === "failed") return { status: "failed" }; + if ( + !payload.finalImageUri || + !Array.isArray(payload.images) || + !payload.images.every((image): image is string => typeof image === "string") + ) { + throw new Error("Completed build manifest is missing artifact metadata"); + } + return { + status: "completed", + finalImageUri: payload.finalImageUri, + images: payload.images, + }; +} - if (payload.serviceId !== serviceId) return false; - if (buildGroupId) return payload.buildGroupId === buildGroupId; - return !payload.buildGroupId && payload.buildId === buildId; - } catch { - return false; +function validateCompletedGroup( + groupBuilds: GroupBuild[], + manifest: Extract, +) { + if (groupBuilds.length === 0) throw new Error("Build group is missing"); + const expectedImages = groupBuilds.map((build) => { + if (build.status !== "completed") { + throw new Error("Build group is not complete"); + } + const expectedImage = platformImageForTarget( + manifest.finalImageUri, + build.targetPlatform, + ); + if (build.imageUri !== expectedImage) { + throw new Error("Platform build artifact does not match its revision"); } + return expectedImage; }); + const expected = [...expectedImages].sort(); + const actual = [...manifest.images].sort(); + if ( + expected.length !== actual.length || + expected.some((image, index) => image !== actual[index]) + ) { + throw new Error( + "Build manifest does not contain the complete platform group", + ); + } } export const buildWorkflow = inngest.createFunction( @@ -51,17 +165,44 @@ export const buildWorkflow = inngest.createFunction( ], }, async ({ event, step }) => { - const { buildId, serviceId, buildGroupId, actor = null } = event.data; + const { serviceId, serviceRevisionId, buildGroupId } = event.data; + const readGroup = () => + getGroupBuilds(serviceId, serviceRevisionId, buildGroupId); - if (!buildGroupId) { - const result = await step.waitForEvent("wait-single-build", { - event: inngestEvents.buildCompleted, - timeout: "60m", - if: `async.data.buildId == "${buildId}"`, - }); + let groupBuilds = await step.run("get-group-builds", readGroup); + if (groupBuilds.length === 0) { + return { status: "failed", reason: "build_group_missing", buildGroupId }; + } + if (groupFailure(groupBuilds)) { + return { status: "failed", reason: "build_failed", buildGroupId }; + } - if (!result) { - await step.run("handle-build-timeout", async () => { + const pendingBuilds = groupBuilds.filter( + (build) => build.status !== "completed", + ); + if (pendingBuilds.length > 0) { + await Promise.all( + pendingBuilds.map((build) => + step.waitForEvent(`wait-build-${build.id}`, { + event: inngestEvents.buildCompleted, + timeout: "60m", + if: `async.data.buildId == "${build.id}"`, + }), + ), + ); + groupBuilds = await step.run("refresh-group-builds", readGroup); + } + + if (groupBuilds.length === 0) { + return { status: "failed", reason: "build_group_missing", buildGroupId }; + } + if (groupFailure(groupBuilds)) { + return { status: "failed", reason: "build_failed", buildGroupId }; + } + if (groupBuilds.some((build) => build.status !== "completed")) { + await step.run("handle-group-timeout", async () => { + for (const build of groupBuilds) { + if (build.status === "completed") continue; await db .update(builds) .set({ @@ -69,140 +210,54 @@ export const buildWorkflow = inngest.createFunction( error: "Build timed out after 60 minutes", completedAt: new Date(), }) - .where(eq(builds.id, buildId)); - }); - return { status: "failed", reason: "timeout", buildId }; - } - - if (result.data.status === "failed") { - return { status: "failed", reason: result.data.error, buildId }; - } - - const manifestAlreadyCompleted = await step.run("check-existing-manifest", async () => { - return hasCompletedManifestWorkItem({ - serviceId, - buildId, - }); - }); - - if (!manifestAlreadyCompleted) { - const manifestResult = await step.waitForEvent("wait-manifest", { - event: inngestEvents.manifestCompleted, - timeout: "10m", - if: `async.data.serviceId == "${serviceId}"`, - }); - - if (!manifestResult) { - return { status: "completed_no_manifest", buildId }; + .where( + and( + eq(builds.id, build.id), + inArray(builds.status, nonTerminalBuildStatuses), + ), + ); } - } - - const shouldDeploy = await step.run("check-auto-deploy", async () => { - const replicas = await db - .select() - .from(serviceReplicas) - .where(eq(serviceReplicas.serviceId, serviceId)); - - const service = await db - .select() - .from(services) - .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) - .then((r) => r[0]); - - return !!service && replicas.some((replica) => replica.count > 0); }); - - if (shouldDeploy) { - await step.run("trigger-deploy", async () => { - await deployServiceInternal(serviceId, actor); - }); + groupBuilds = await step.run("refresh-group-after-timeout", readGroup); + if (groupBuilds.some((build) => build.status !== "completed")) { + return { status: "failed", reason: "timeout", buildGroupId }; } - - return { status: "completed", buildId }; } - const groupBuilds = await step.run("get-group-builds", async () => { - return db - .select() - .from(builds) - .where(eq(builds.buildGroupId, buildGroupId)); - }); - - const buildResults = await Promise.all( - groupBuilds.map((build) => - step.waitForEvent(`wait-build-${build.id}`, { - event: inngestEvents.buildCompleted, - timeout: "60m", - if: `async.data.buildId == "${build.id}"`, - }), - ), + const manifestIdentity = { serviceId, serviceRevisionId, buildGroupId }; + let manifest = await step.run("check-existing-group-manifest", () => + manifestState(manifestIdentity), ); - - const timedOut = buildResults.some((r) => r === null); - if (timedOut) { - await step.run("handle-group-timeout", async () => { - for (const build of groupBuilds) { - const result = buildResults[groupBuilds.indexOf(build)]; - if (result === null) { - await db - .update(builds) - .set({ - status: "failed", - error: "Build timed out after 60 minutes", - completedAt: new Date(), - }) - .where(eq(builds.id, build.id)); - } - } - }); - return { status: "failed", reason: "timeout", buildGroupId }; - } - - const failed = buildResults.some((r) => r?.data.status === "failed"); - if (failed) { - return { status: "failed", reason: "build_failed", buildGroupId }; - } - - const manifestAlreadyCompleted = await step.run("check-existing-group-manifest", async () => { - return hasCompletedManifestWorkItem({ - serviceId, - buildGroupId, - }); - }); - - if (!manifestAlreadyCompleted) { - const manifestResult = await step.waitForEvent("wait-group-manifest", { + if (!manifest) { + await step.waitForEvent("wait-group-manifest", { event: inngestEvents.manifestCompleted, timeout: "10m", - if: `async.data.buildGroupId == "${buildGroupId}"`, + if: `async.data.serviceRevisionId == "${serviceRevisionId}" && async.data.buildGroupId == "${buildGroupId}"`, }); - - if (!manifestResult) { - return { status: "completed_no_manifest", buildGroupId }; - } + manifest = await step.run("check-group-manifest-after-wait", () => + manifestState(manifestIdentity), + ); } - - const shouldDeploy = await step.run("check-auto-deploy-group", async () => { - const replicas = await db - .select() - .from(serviceReplicas) - .where(eq(serviceReplicas.serviceId, serviceId)); - - const service = await db - .select() - .from(services) - .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) - .then((r) => r[0]); - - return !!service && replicas.some((replica) => replica.count > 0); - }); - - if (shouldDeploy) { - await step.run("trigger-deploy-group", async () => { - await deployServiceInternal(serviceId, actor); - }); + if (!manifest) { + return { status: "completed_no_manifest", buildGroupId }; + } + if (manifest.status === "failed") { + return { status: "failed", reason: "manifest_failed", buildGroupId }; } - return { status: "completed", buildGroupId }; + groupBuilds = await step.run("validate-group-before-deploy", readGroup); + validateCompletedGroup(groupBuilds, manifest); + const deployment = await step.run("trigger-deploy-group", () => + deployServiceRevisionInternal( + serviceId, + serviceRevisionId, + manifest.finalImageUri, + ), + ); + return { + status: "completed", + buildGroupId, + rolloutId: deployment.rolloutId, + }; }, ); diff --git a/web/lib/inngest/functions/migration-workflow.ts b/web/lib/inngest/functions/migration-workflow.ts index f1d53898..6ac80c79 100644 --- a/web/lib/inngest/functions/migration-workflow.ts +++ b/web/lib/inngest/functions/migration-workflow.ts @@ -28,6 +28,7 @@ export const migrationWorkflow = inngest.createFunction( targetServerId, sourceServerId, sourceDeploymentId, + sourceServiceRevisionId, sourceContainerId, volumes, actor = null, @@ -116,59 +117,61 @@ export const migrationWorkflow = inngest.createFunction( const backupResults = await Promise.all( backupIds.map((backupId) => - group.parallel(async (): Promise<{ - status: "completed" | "failed" | "pending" | "timed_out"; - error?: string; - }> => { - const readBackup = async () => - db - .select({ - status: volumeBackups.status, - errorMessage: volumeBackups.errorMessage, - }) - .from(volumeBackups) - .where(eq(volumeBackups.id, backupId)) - .then((r) => r[0]); - - const before = await step.run( - `check-backup-${backupId}-before`, - readBackup, - ); - if (before?.status === "completed") { - return { status: "completed" as const }; - } - if (before?.status === "failed") { - return { - status: "failed" as const, - error: before.errorMessage || "Backup failed", - }; - } - - const wakeup = await step.waitForEvent( - `wait-backup-status-${backupId}`, - { - event: inngestEvents.resourceStatusChanged, - timeout: "30m", - if: `async.data.type == "backup" && async.data.id == "${backupId}"`, - }, - ); - - const after = await step.run( - `check-backup-${backupId}-after`, - readBackup, - ); - if (after?.status === "completed") { - return { status: "completed" as const }; - } - if (after?.status === "failed") { - return { - status: "failed" as const, - error: after.errorMessage || "Backup failed", - }; - } - - return { status: wakeup ? "pending" : "timed_out" } as const; - }), + group.parallel( + async (): Promise<{ + status: "completed" | "failed" | "pending" | "timed_out"; + error?: string; + }> => { + const readBackup = async () => + db + .select({ + status: volumeBackups.status, + errorMessage: volumeBackups.errorMessage, + }) + .from(volumeBackups) + .where(eq(volumeBackups.id, backupId)) + .then((r) => r[0]); + + const before = await step.run( + `check-backup-${backupId}-before`, + readBackup, + ); + if (before?.status === "completed") { + return { status: "completed" as const }; + } + if (before?.status === "failed") { + return { + status: "failed" as const, + error: before.errorMessage || "Backup failed", + }; + } + + const wakeup = await step.waitForEvent( + `wait-backup-status-${backupId}`, + { + event: inngestEvents.resourceStatusChanged, + timeout: "30m", + if: `async.data.type == "backup" && async.data.id == "${backupId}"`, + }, + ); + + const after = await step.run( + `check-backup-${backupId}-after`, + readBackup, + ); + if (after?.status === "completed") { + return { status: "completed" as const }; + } + if (after?.status === "failed") { + return { + status: "failed" as const, + error: after.errorMessage || "Backup failed", + }; + } + + return { status: wakeup ? "pending" : "timed_out" } as const; + }, + ), ), ); @@ -186,7 +189,9 @@ export const migrationWorkflow = inngest.createFunction( return { status: "failed", reason: "backup_timeout" }; } - const backupStillPending = backupResults.some((r) => r.status === "pending"); + const backupStillPending = backupResults.some( + (r) => r.status === "pending", + ); if (backupStillPending) { await step.run("handle-backup-still-pending", async () => { await db @@ -288,8 +293,7 @@ export const migrationWorkflow = inngest.createFunction( .update(services) .set({ migrationStatus: "failed", - migrationError: - restoreFailure.data.error || "Restore failed", + migrationError: restoreFailure.data.error || "Restore failed", }) .where(eq(services.id, serviceId)); }); @@ -318,7 +322,9 @@ export const migrationWorkflow = inngest.createFunction( .set({ lockedServerId: targetServerId }) .where(eq(services.id, serviceId)); - await deployServiceInternal(serviceId, actor); + await deployServiceInternal(serviceId, actor, { + runtimeBaseRevisionId: sourceServiceRevisionId, + }); }); await step.run("finalize-migration", async () => { diff --git a/web/lib/inngest/functions/service-deletion-workflow.ts b/web/lib/inngest/functions/service-deletion-workflow.ts index ef7f20f9..88c06bf6 100644 --- a/web/lib/inngest/functions/service-deletion-workflow.ts +++ b/web/lib/inngest/functions/service-deletion-workflow.ts @@ -1,20 +1,32 @@ import { randomUUID } from "node:crypto"; -import { and, eq, inArray, isNotNull, isNull, lte, or } from "drizzle-orm"; +import { + and, + desc, + eq, + inArray, + isNotNull, + isNull, + lte, + or, +} from "drizzle-orm"; import { cron } from "inngest"; -import { deleteBackupInternal } from "@/lib/backups/delete-backup"; import { db } from "@/db"; import { getBackupStorageConfig } from "@/db/queries"; import { deploymentPorts, deployments, + rollouts, secrets, + serviceRevisions, services, serviceVolumes, volumeBackups, } from "@/db/schema"; +import { deleteBackupInternal } from "@/lib/backups/delete-backup"; import { addUtcDays, toDate } from "@/lib/date"; import { deployServiceInternal } from "@/lib/deploy-service"; import { markDeploymentRemoved } from "@/lib/deployment-status"; +import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; import { enqueueWork } from "@/lib/work-queue"; import { inngest } from "../client"; import { inngestEvents } from "../events"; @@ -310,6 +322,55 @@ export const serviceRestoreWorkflow = inngest.createFunction( if (!service || !service.deletedAt) { throw new Error("Deleted service not found"); } + const runtimeBaseRevision = + service.sourceType === "github" + ? await db + .select({ + id: rollouts.serviceRevisionId, + specification: serviceRevisions.specification, + }) + .from(rollouts) + .innerJoin( + serviceRevisions, + and( + eq(serviceRevisions.id, rollouts.serviceRevisionId), + eq(serviceRevisions.serviceId, rollouts.serviceId), + ), + ) + .where( + and( + eq(rollouts.serviceId, serviceId), + eq(rollouts.status, "completed"), + ), + ) + .orderBy( + desc(rollouts.completedAt), + desc(rollouts.createdAt), + desc(rollouts.id), + ) + .limit(1) + .then((rows) => rows[0]) + : undefined; + if (service.sourceType === "github" && !runtimeBaseRevision) { + throw new Error( + "Cannot restore GitHub service without a completed runtime revision", + ); + } + if (runtimeBaseRevision) { + let baseSpecification: ReturnType; + try { + baseSpecification = parseServiceRevisionSpec( + runtimeBaseRevision.specification, + ); + } catch { + throw new Error("GitHub runtime base revision is invalid"); + } + if (baseSpecification.source.type !== "github") { + throw new Error( + "GitHub runtime base revision is not a GitHub build", + ); + } + } const resolvedTargetServerId = targetServerId ?? service.lockedServerId; if (!resolvedTargetServerId) { @@ -339,6 +400,7 @@ export const serviceRestoreWorkflow = inngest.createFunction( storageConfig, service, targetServerId: resolvedTargetServerId, + runtimeBaseRevisionId: runtimeBaseRevision?.id, backups, }; }); @@ -421,7 +483,9 @@ export const serviceRestoreWorkflow = inngest.createFunction( .where(eq(services.id, serviceId)); try { - const result = await deployServiceInternal(serviceId, actor); + const result = await deployServiceInternal(serviceId, actor, { + runtimeBaseRevisionId: setup.runtimeBaseRevisionId, + }); if (!("rolloutId" in result) || !result.rolloutId) { throw new Error("Restore could not start a deployment"); } diff --git a/web/lib/migrations.ts b/web/lib/migrations.ts index 82b82db6..20b51751 100644 --- a/web/lib/migrations.ts +++ b/web/lib/migrations.ts @@ -1,10 +1,16 @@ import { and, eq, inArray } from "drizzle-orm"; import { db } from "@/db"; import { getBackupStorageConfig } from "@/db/queries"; -import { deployments, services, serviceVolumes } from "@/db/schema"; +import { + deployments, + serviceRevisions, + services, + serviceVolumes, +} from "@/db/schema"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; import type { ServiceRevisionActor } from "@/lib/service-revision-actor"; +import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; export async function startMigrationInternal( serviceId: string, @@ -50,11 +56,13 @@ export async function startMigrationInternal( id: deployments.id, serverId: deployments.serverId, containerId: deployments.containerId, + serviceRevisionId: deployments.serviceRevisionId, }) .from(deployments) .where( and( eq(deployments.serviceId, serviceId), + eq(deployments.trafficState, "active"), inArray(deployments.observedPhase, ["healthy", "running"]), ), ) @@ -71,6 +79,29 @@ export async function startMigrationInternal( if (deployment.serverId === targetServerId) { throw new Error("Service is already running on the target server"); } + if (service.sourceType === "github") { + const baseRevision = await db + .select({ specification: serviceRevisions.specification }) + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.id, deployment.serviceRevisionId), + eq(serviceRevisions.serviceId, serviceId), + ), + ) + .then((rows) => rows[0]); + if (!baseRevision) + throw new Error("Runtime base service revision not found"); + let specification: ReturnType; + try { + specification = parseServiceRevisionSpec(baseRevision.specification); + } catch { + throw new Error("GitHub runtime base revision is invalid"); + } + if (specification.source.type !== "github") { + throw new Error("GitHub runtime base revision is not a GitHub build"); + } + } await db .update(services) @@ -88,6 +119,7 @@ export async function startMigrationInternal( targetServerId, sourceServerId: deployment.serverId, sourceDeploymentId: deployment.id, + sourceServiceRevisionId: deployment.serviceRevisionId, sourceContainerId: deployment.containerId, volumes: volumes.map((v) => ({ id: v.id, name: v.name })), actor, diff --git a/web/lib/public-api-pagination.ts b/web/lib/public-api-pagination.ts new file mode 100644 index 00000000..9e14ebe4 --- /dev/null +++ b/web/lib/public-api-pagination.ts @@ -0,0 +1,162 @@ +type NamedCursor = { + name: string; + id: string; +}; + +export type NamedPage = { + limit: number; + cursor: NamedCursor | undefined; +}; + +export type TimestampCursor = { + createdAt: string; + id: string; +}; + +const timestampPattern = + /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|([+-])(\d{2})(?::?(\d{2}))?)$/; + +function isValidTimestamp(value: string): boolean { + const match = timestampPattern.exec(value); + if (!match) return false; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = Number(match[8] ?? 0); + const offsetMinute = Number(match[9] ?? 0); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [ + 31, + leapYear ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ][month - 1]; + + return ( + year >= 1 && + daysInMonth !== undefined && + day >= 1 && + day <= daysInMonth && + hour <= 23 && + minute <= 59 && + second <= 59 && + (offsetHour < 14 || (offsetHour === 14 && offsetMinute === 0)) && + offsetMinute <= 59 + ); +} + +export function encodeTimestampCursor(value: TimestampCursor): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +export function decodeTimestampCursor( + value: string | null, +): TimestampCursor | null | undefined { + if (!value) return undefined; + if (value.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value)) return null; + + try { + const parsed = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ) as Partial; + if ( + typeof parsed.id !== "string" || + parsed.id.length < 1 || + parsed.id.length > 200 || + typeof parsed.createdAt !== "string" || + !isValidTimestamp(parsed.createdAt) + ) { + return null; + } + return { id: parsed.id, createdAt: parsed.createdAt }; + } catch { + return null; + } +} + +export function timestampPage(url: URL): { + limit: number; + cursor: TimestampCursor | undefined; +} { + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit === null ? 25 : Number(rawLimit); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new RangeError("limit must be an integer from 1 to 100"); + } + + const cursor = decodeTimestampCursor(url.searchParams.get("cursor")); + if (cursor === null) throw new RangeError("Invalid cursor"); + return { limit, cursor }; +} + +export function nextTimestampCursor< + T extends { id: string; cursorCreatedAt: string }, +>(items: T[], limit: number): string | null { + const last = items.slice(0, limit).at(-1); + return items.length > limit && last + ? encodeTimestampCursor({ + id: last.id, + createdAt: last.cursorCreatedAt, + }) + : null; +} + +function decodeCursor(value: string | null): NamedCursor | null | undefined { + if (!value) return undefined; + if (value.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value)) return null; + + try { + const parsed = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ) as Partial; + if ( + typeof parsed.name !== "string" || + parsed.name.length > 512 || + typeof parsed.id !== "string" || + parsed.id.length < 1 || + parsed.id.length > 200 + ) { + return null; + } + return { name: parsed.name, id: parsed.id }; + } catch { + return null; + } +} + +export function namedPage(url: URL): NamedPage { + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit === null ? 100 : Number(rawLimit); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new RangeError("limit must be an integer from 1 to 100"); + } + + const cursor = decodeCursor(url.searchParams.get("cursor")); + if (cursor === null) throw new RangeError("Invalid cursor"); + return { limit, cursor }; +} + +export function nextNamedCursor( + items: T[], + limit: number, +): string | null { + const last = items.slice(0, limit).at(-1); + return items.length > limit && last + ? Buffer.from( + JSON.stringify({ name: last.name, id: last.id }), + "utf8", + ).toString("base64url") + : null; +} diff --git a/web/lib/public-api-routes.ts b/web/lib/public-api-routes.ts new file mode 100644 index 00000000..6af35edf --- /dev/null +++ b/web/lib/public-api-routes.ts @@ -0,0 +1,733 @@ +import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { builds, deployments, rollouts, servers } from "@/db/schema"; +import { requireApiKeyDeveloperRole, requireApiKeyRole } from "@/lib/api-auth"; +import { deployServiceInternal } from "@/lib/deploy-service"; +import { + DEFAULT_LOG_TIME_RANGE, + isLogCursor, + LOG_TIME_RANGES, + normalizeLogSearch, + parseLogLimit, +} from "@/lib/log-query"; +import { METRIC_RANGE_KEYS } from "@/lib/metric-ranges"; +import { + apiError, + badRequest, + configurationPatchSchema, + findNestedService, + isPublicApiDomainError, + notFound, + patchConfiguration, + publicApiDomainResponse, + resolvePersistedSource, + safeConfiguration, +} from "@/lib/public-api"; +import { + decodeTimestampCursor, + nextTimestampCursor, + type TimestampCursor, + timestampPage, +} from "@/lib/public-api-pagination"; +import { queryServiceRevisionChangelog } from "@/lib/service-revision-changelog"; +import { + isLoggingEnabled, + isPublicServiceLogEventId, + type PublicServiceLogCursor, + queryLogsByRollout, + queryPublicServiceLogs, + ServiceLogCursorUnavailableError, + type StoredLog, +} from "@/lib/victoria-logs"; +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; + +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() }; + } catch (error) { + return { response: internalError(error, "resolve service scope") }; + } +} + +async function writeScope(request: Request, context: PublicServiceContext) { + const auth = await requireApiKeyDeveloperRole(request); + 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() }; + } catch (error) { + return { response: internalError(error, "resolve service scope") }; + } +} + +function cursorFilter( + table: T, + cursor: TimestampCursor | undefined, +) { + return cursor + ? or( + lt(table.createdAt as never, sql`${cursor.createdAt}::timestamptz`), + and( + eq(table.createdAt as never, sql`${cursor.createdAt}::timestamptz`), + lt(table.id as never, cursor.id), + ), + ) + : undefined; +} +function internalError(error: unknown, operation: string) { + console.error(`[public-api] ${operation} failed`, error); + return apiError("Internal server error", "INTERNAL_ERROR", 500); +} + +export async function getConfiguration( + request: Request, + context: PublicServiceContext, +) { + const scope = await readScope(request, context); + if ("response" in scope) return scope.response; + try { + return Response.json(await safeConfiguration(scope.service)); + } catch (error) { + return internalError(error, "read configuration"); + } +} + +export async function patchConfigurationRoute( + request: Request, + context: PublicServiceContext, +) { + const scope = await writeScope(request, context); + if ("response" in scope) return scope.response; + const parsed = configurationPatchSchema.safeParse( + await request.json().catch(() => null), + ); + if (!parsed.success) { + return badRequest( + parsed.error.issues[0]?.message ?? "Invalid configuration", + ); + } + try { + return Response.json(await patchConfiguration(scope.service, parsed.data)); + } catch (error) { + return isPublicApiDomainError(error) + ? publicApiDomainResponse(error) + : internalError(error, "patch configuration"); + } +} + +const safeDeployment = { + id: deployments.id, + serviceRevisionId: deployments.serviceRevisionId, + rolloutId: deployments.rolloutId, + serverId: deployments.serverId, + serverName: servers.name, + desiredState: deployments.runtimeDesiredState, + trafficState: deployments.trafficState, + phase: deployments.observedPhase, + healthStatus: deployments.healthStatus, + failedStage: deployments.failedStage, + createdAt: deployments.createdAt, +}; +const safeRollout = { + id: rollouts.id, + serviceRevisionId: rollouts.serviceRevisionId, + status: rollouts.status, + currentStage: rollouts.currentStage, + createdAt: rollouts.createdAt, + completedAt: rollouts.completedAt, +}; +const safeBuild = { + id: builds.id, + serviceRevisionId: builds.serviceRevisionId, + commitSha: builds.commitSha, + commitMessage: builds.commitMessage, + branch: builds.branch, + author: builds.author, + status: builds.status, + targetPlatform: builds.targetPlatform, + startedAt: builds.startedAt, + completedAt: builds.completedAt, + createdAt: builds.createdAt, +}; +const paginatedRollout = { + ...safeRollout, + cursorCreatedAt: sql`${rollouts.createdAt}::text`, +}; +const paginatedBuild = { + ...safeBuild, + cursorCreatedAt: sql`${builds.createdAt}::text`, +}; + +export async function getStatus( + request: Request, + context: PublicServiceContext, +) { + const scope = await readScope(request, context); + if ("response" in scope) return scope.response; + try { + const [latestRollout, latestBuild, persisted, source] = await Promise.all([ + db + .select(safeRollout) + .from(rollouts) + .where(eq(rollouts.serviceId, scope.service.id)) + .orderBy(desc(rollouts.createdAt), desc(rollouts.id)) + .limit(1) + .then((rows) => rows[0] ?? null), + db + .select(safeBuild) + .from(builds) + .where(eq(builds.serviceId, scope.service.id)) + .orderBy(desc(builds.createdAt), desc(builds.id)) + .limit(1) + .then((rows) => rows[0] ?? null), + db + .select(safeDeployment) + .from(deployments) + .innerJoin(servers, eq(servers.id, deployments.serverId)) + .where(eq(deployments.serviceId, scope.service.id)) + .orderBy(desc(deployments.createdAt)) + .limit(100), + resolvePersistedSource(scope.service), + ]); + return Response.json({ + service: { + id: scope.service.id, + name: scope.service.name, + source, + }, + latestBuild: scope.service.sourceType === "github" ? latestBuild : null, + latestRollout, + deployments: persisted, + }); + } catch (error) { + return internalError(error, "read status"); + } +} + +function deployConflict(error: unknown) { + const message = error instanceof Error ? error.message : "Deployment failed"; + if ( + /replica|placement|server|migration|GitHub repository|GitHub service/i.test( + message, + ) + ) { + return apiError(message, "DEPLOYMENT_CONFLICT", 409); + } + return apiError( + "Deployment provider unavailable", + "DEPLOY_PROVIDER_ERROR", + 502, + ); +} + +export async function postDeploy( + request: Request, + context: PublicServiceContext, +) { + const scope = await writeScope(request, context); + if ("response" in scope) return scope.response; + const actor = { + type: "user" as const, + userId: scope.auth.session.user.id, + name: scope.auth.session.user.name, + }; + try { + const result = await deployServiceInternal(scope.service.id, actor, { + githubTrigger: "manual", + }); + if ( + scope.service.sourceType === "github" && + !("migrationStarted" in result) + ) { + return Response.json( + { + operation: "build", + status: "build_queued", + rolloutId: null, + buildId: "buildId" in result ? result.buildId : null, + }, + { status: 202 }, + ); + } + return Response.json( + { + operation: "rollout", + status: "migrationStarted" in result ? "migration_started" : "queued", + rolloutId: "rolloutId" in result ? result.rolloutId : null, + buildId: null, + }, + { status: 202 }, + ); + } catch (error) { + return deployConflict(error); + } +} + +function waitForPoll(delayMs: number, signal?: AbortSignal) { + if (signal?.aborted || delayMs <= 0) return Promise.resolve(); + return new Promise((resolve) => { + let timer: ReturnType; + const finish = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", finish); + resolve(); + }; + timer = setTimeout(finish, delayMs); + signal?.addEventListener("abort", finish, { once: true }); + }); +} + +export async function longPollLogs( + query: () => Promise, + options: { waitMs: number; signal?: AbortSignal; intervalMs?: number }, +) { + if (options.signal?.aborted) { + throw options.signal.reason ?? new DOMException("Aborted", "AbortError"); + } + const deadline = Date.now() + options.waitMs; + let result = await query(); + while ( + result.logs.length === 0 && + Date.now() < deadline && + !options.signal?.aborted + ) { + await waitForPoll( + Math.min(options.intervalMs ?? 500, Math.max(0, deadline - Date.now())), + options.signal, + ); + if (!options.signal?.aborted && Date.now() < deadline) { + result = await query(); + } + } + return result; +} + +type ServiceLogCursorV1 = { + v: 1; + t: string; + e: string; +}; + +export function encodeServiceLogCursor(cursor: ServiceLogCursorV1): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +export function decodeServiceLogCursor( + value: string | null, +): ServiceLogCursorV1 | null | undefined { + if (!value) return undefined; + if (value.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value)) return null; + try { + const parsed = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ) as Partial; + if ( + parsed.v !== 1 || + typeof parsed.t !== "string" || + !isLogCursor(parsed.t) || + typeof parsed.e !== "string" || + (parsed.e !== "" && !isPublicServiceLogEventId(parsed.e)) + ) { + return null; + } + return { v: 1, t: parsed.t, e: parsed.e }; + } catch { + return null; + } +} + +function logOptions(url: URL) { + const rawCursor = url.searchParams.get("cursor"); + const cursor = decodeServiceLogCursor(rawCursor); + if (cursor === null) throw new RangeError("Invalid log cursor"); + if (url.searchParams.has("after") || url.searchParams.has("before")) { + throw new RangeError("Use the opaque cursor parameter for log pagination"); + } + const rangeValue = url.searchParams.get("range"); + const range = rangeValue + ? LOG_TIME_RANGES.find((value) => value === rangeValue) + : DEFAULT_LOG_TIME_RANGE; + if (rangeValue && !range) throw new RangeError("Invalid log range"); + const waitRaw = url.searchParams.get("wait"); + const wait = waitRaw === null ? 0 : Number(waitRaw); + if (!Number.isInteger(wait) || wait < 0 || wait > 20) { + throw new RangeError("wait must be an integer from 0 to 20"); + } + return { + limit: parseLogLimit( + url.searchParams.get("limit") ?? url.searchParams.get("tail"), + 100, + ), + cursor, + rawCursor, + range, + search: normalizeLogSearch(url.searchParams.get("q")), + wait, + }; +} + +function invalidLogQuery(error: RangeError) { + return badRequest(error.message, "INVALID_LOG_QUERY"); +} + +function publicServiceLog(log: StoredLog) { + return { + deploymentId: log.deployment_id ?? null, + stream: log.stream ?? "stdout", + message: log._msg, + timestamp: log._time, + }; +} + +export function nextServiceLogCursor( + logs: StoredLog[], + rawCursor: string | null, +): string { + const lastIdentified = logs.findLast((log) => + isPublicServiceLogEventId(log.event_id), + ); + const eventId = lastIdentified?.event_id; + if (lastIdentified && eventId) { + return encodeServiceLogCursor({ + v: 1, + t: lastIdentified._time, + e: eventId, + }); + } + const last = logs.at(-1); + if (last) { + return encodeServiceLogCursor({ v: 1, t: last._time, e: "" }); + } + if (rawCursor) return rawCursor; + return encodeServiceLogCursor({ + v: 1, + t: new Date(Date.now() - 15_000).toISOString(), + e: "", + }); +} + +export async function getServiceLogs( + request: Request, + context: PublicServiceContext, +) { + const scope = await readScope(request, context); + if ("response" in scope) return scope.response; + if (!isLoggingEnabled()) { + return Response.json({ + provider: "disabled", + logs: [], + nextCursor: null, + hasMore: false, + pollAfterMs: 2000, + }); + } + try { + const options = logOptions(new URL(request.url)); + const query = () => + queryPublicServiceLogs({ + serviceId: scope.service.id, + logType: "container", + limit: options.limit, + cursor: options.cursor + ? ({ + time: options.cursor.t, + eventId: options.cursor.e, + } satisfies PublicServiceLogCursor) + : undefined, + range: options.range, + search: options.search, + signal: request.signal, + }); + const result = + options.cursor && options.wait > 0 + ? await longPollLogs(query, { + waitMs: options.wait * 1000, + signal: request.signal, + }) + : await query(); + return Response.json({ + provider: "enabled", + logs: result.logs.map(publicServiceLog), + nextCursor: nextServiceLogCursor(result.logs, options.rawCursor), + hasMore: result.hasMore, + pollAfterMs: result.hasMore ? 0 : result.logs.length > 0 ? 250 : 1000, + }); + } catch (error) { + if (request.signal.aborted) return new Response(null, { status: 499 }); + if (error instanceof ServiceLogCursorUnavailableError) { + return apiError(error.message, "LOG_CURSOR_UNAVAILABLE", 409); + } + return error instanceof RangeError + ? invalidLogQuery(error) + : apiError("Log provider unavailable", "LOG_PROVIDER_ERROR", 502); + } +} + +export async function getRollouts( + request: Request, + context: PublicServiceContext, +) { + const scope = await readScope(request, context); + if ("response" in scope) return scope.response; + let page: ReturnType; + try { + page = timestampPage(new URL(request.url)); + } catch (error) { + return badRequest((error as Error).message, "INVALID_CURSOR"); + } + try { + const rows = await db + .select(paginatedRollout) + .from(rollouts) + .where( + and( + eq(rollouts.serviceId, scope.service.id), + cursorFilter(rollouts, page.cursor), + ), + ) + .orderBy(desc(rollouts.createdAt), desc(rollouts.id)) + .limit(page.limit + 1); + const pageRows = rows.slice(0, page.limit); + const ids = pageRows.map((row) => row.id); + const states = ids.length + ? await db + .select(safeDeployment) + .from(deployments) + .innerJoin(servers, eq(servers.id, deployments.serverId)) + .where( + and( + eq(deployments.serviceId, scope.service.id), + inArray(deployments.rolloutId, ids), + ), + ) + : []; + return Response.json({ + rollouts: pageRows.map(({ cursorCreatedAt: _, ...rollout }) => ({ + ...rollout, + deployments: states.filter( + (deployment) => deployment.rolloutId === rollout.id, + ), + })), + nextCursor: nextTimestampCursor(rows, page.limit), + }); + } catch (error) { + return internalError(error, "list rollouts"); + } +} + +type RolloutContext = PublicServiceContext & { + params: Promise; +}; + +export async function getRollout(request: Request, context: RolloutContext) { + const scope = await readScope(request, context); + if ("response" in scope) return scope.response; + const rolloutId = (await context.params).rolloutId; + try { + const rollout = await db + .select(safeRollout) + .from(rollouts) + .where( + and( + eq(rollouts.id, rolloutId), + eq(rollouts.serviceId, scope.service.id), + ), + ) + .limit(1) + .then((rows) => rows[0]); + if (!rollout) return notFound(); + const state = await db + .select(safeDeployment) + .from(deployments) + .innerJoin(servers, eq(servers.id, deployments.serverId)) + .where( + and( + eq(deployments.rolloutId, rollout.id), + eq(deployments.serviceId, scope.service.id), + ), + ); + return Response.json({ rollout: { ...rollout, deployments: state } }); + } catch (error) { + return internalError(error, "read rollout"); + } +} + +export async function getRolloutLogs( + request: Request, + context: RolloutContext, +) { + const scope = await readScope(request, context); + if ("response" in scope) return scope.response; + let rolloutId: string; + try { + rolloutId = (await context.params).rolloutId; + const exists = await db + .select({ id: rollouts.id }) + .from(rollouts) + .where( + and( + eq(rollouts.id, rolloutId), + eq(rollouts.serviceId, scope.service.id), + ), + ) + .limit(1) + .then((rows) => rows[0]); + if (!exists) return notFound(); + } catch (error) { + return internalError(error, "resolve rollout logs scope"); + } + if (!isLoggingEnabled()) { + return Response.json({ provider: "disabled", logs: [] }); + } + try { + const url = new URL(request.url); + const limit = parseLogLimit(url.searchParams.get("limit"), 100); + const search = normalizeLogSearch(url.searchParams.get("q")); + const result = await queryLogsByRollout(rolloutId, { limit, search }); + return Response.json({ + provider: "enabled", + logs: result.logs.map((log) => ({ + stage: log.stage, + message: log._msg, + timestamp: log._time, + })), + }); + } catch (error) { + return error instanceof RangeError + ? invalidLogQuery(error) + : apiError("Log provider unavailable", "LOG_PROVIDER_ERROR", 502); + } +} + +export async function getBuilds( + request: Request, + context: PublicServiceContext, +) { + const scope = await readScope(request, context); + if ("response" in scope) return scope.response; + if (scope.service.sourceType !== "github") { + return Response.json({ + supported: false, + reason: "IMAGE_SOURCE", + builds: [], + nextCursor: null, + }); + } + let page: ReturnType; + try { + page = timestampPage(new URL(request.url)); + } catch (error) { + return badRequest((error as Error).message, "INVALID_CURSOR"); + } + try { + const rows = await db + .select(paginatedBuild) + .from(builds) + .where( + and( + eq(builds.serviceId, scope.service.id), + cursorFilter(builds, page.cursor), + ), + ) + .orderBy(desc(builds.createdAt), desc(builds.id)) + .limit(page.limit + 1); + return Response.json({ + supported: true, + builds: rows + .slice(0, page.limit) + .map(({ cursorCreatedAt: _, ...build }) => build), + nextCursor: nextTimestampCursor(rows, page.limit), + }); + } catch (error) { + return internalError(error, "list builds"); + } +} + +export async function getMetrics( + request: Request, + context: PublicServiceContext, +) { + const scope = await readScope(request, context); + if ("response" in scope) return scope.response; + const range = new URL(request.url).searchParams.get("range") ?? "1h"; + if (!METRIC_RANGE_KEYS.includes(range as never)) { + return badRequest("Invalid metrics range", "INVALID_METRICS_RANGE"); + } + if (!isMetricsEnabled()) { + return Response.json({ provider: "disabled", metrics: null }); + } + try { + return Response.json({ + provider: "enabled", + metrics: await queryServiceMetrics({ + serviceId: scope.service.id, + range: range as (typeof METRIC_RANGE_KEYS)[number], + throwOnError: true, + }), + }); + } catch { + return apiError( + "Metrics provider unavailable", + "METRICS_PROVIDER_ERROR", + 502, + ); + } +} + +export async function getRevisions( + request: Request, + context: PublicServiceContext, +) { + const scope = await readScope(request, context); + if ("response" in scope) return scope.response; + const cursorValue = new URL(request.url).searchParams.get("cursor"); + const cursor = decodeTimestampCursor(cursorValue); + if (cursorValue && !cursor) { + return badRequest("Invalid revision cursor", "INVALID_CURSOR"); + } + try { + const body = await queryServiceRevisionChangelog( + scope.service.id, + cursor ?? undefined, + ); + return Response.json({ + ...body, + revisions: body.revisions.map((revision) => + revision.comparison.kind === "changes" + ? { + ...revision, + comparison: { + ...revision.comparison, + changes: revision.comparison.changes.filter( + (change) => change.field !== "Secret", + ), + }, + } + : revision, + ), + }); + } catch (error) { + return internalError(error, "list revisions"); + } +} diff --git a/web/lib/public-api.ts b/web/lib/public-api.ts new file mode 100644 index 00000000..c782adf7 --- /dev/null +++ b/web/lib/public-api.ts @@ -0,0 +1,871 @@ +import { randomUUID } from "node:crypto"; +import { and, desc, eq, isNull, ne } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "@/db"; +import { + deployments, + environments, + githubRepos, + projects, + servers, + servicePorts, + serviceReplicas, + serviceRevisions, + services, + serviceVolumes, +} from "@/db/schema"; +import { validateDockerImageInternal } from "@/lib/docker-image"; +import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; +import { getDefaultServiceHostname } from "@/lib/service-revision-spec"; + +const githubPathPart = /^[A-Za-z0-9_.-]+$/; +const windowsAbsolutePath = /^[A-Za-z]:[\\/]/; + +export function canonicalGitHubRepository(value: string): string { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + throw new Error("Invalid GitHub repository URL"); + } + if ( + url.protocol !== "https:" || + url.hostname.toLowerCase() !== "github.com" || + url.port || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error( + "Repository must be an HTTPS github.com URL without credentials, port, query, or fragment", + ); + } + + const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean); + if (parts.length !== 2) throw new Error("Invalid GitHub repository path"); + const owner = parts[0]; + const repository = parts[1].replace(/\.git$/i, ""); + if ( + !owner || + !repository || + owner === "." || + owner === ".." || + repository === "." || + repository === ".." || + !githubPathPart.test(owner) || + !githubPathPart.test(repository) + ) { + throw new Error("Invalid GitHub repository path"); + } + return `https://github.com/${owner}/${repository}`; +} + +export function isSafeRepositoryRoot(value: string): boolean { + const rootDir = value.trim(); + if ( + !rootDir || + rootDir.startsWith("/") || + rootDir.startsWith("\\") || + windowsAbsolutePath.test(rootDir) + ) { + return false; + } + return !rootDir.split(/[\\/]+/).includes(".."); +} + +const rootDirSchema = z + .string() + .trim() + .max(512) + .refine( + isSafeRepositoryRoot, + "rootDir must be repository-relative and cannot contain '..'", + ) + .transform((value) => value.replaceAll("\\", "/")); +const githubRepositorySchema = z + .string() + .trim() + .refine((value) => { + try { + canonicalGitHubRepository(value); + return true; + } catch { + return false; + } + }, "Invalid GitHub repository URL") + .transform(canonicalGitHubRepository); + +export const publicSourceSchema = z.discriminatedUnion("type", [ + z.strictObject({ + type: z.literal("image"), + image: z.string().trim().min(1).max(2048), + }), + z.strictObject({ + type: z.literal("github"), + repository: githubRepositorySchema, + branch: z.string().trim().min(1).max(255), + rootDir: rootDirSchema.nullable().optional(), + }), +]); + +export type PublicSource = + | { type: "image"; image: string } + | { + type: "github"; + repository: string | null; + branch: string; + rootDir?: string; + }; +export type NestedService = typeof services.$inferSelect; +type GitHubRepo = typeof githubRepos.$inferSelect; + +function resolveRepository( + service: NestedService, + repo: GitHubRepo | undefined, +): string | null { + if (repo?.repoFullName) { + try { + return canonicalGitHubRepository( + `https://github.com/${repo.repoFullName}`, + ); + } catch { + return null; + } + } + if (!service.githubRepoUrl) return null; + try { + return canonicalGitHubRepository(service.githubRepoUrl); + } catch { + return null; + } +} + +export function resolvePersistedSourceFromRows( + service: NestedService, + repo: GitHubRepo | undefined, +): PublicSource { + if (service.sourceType === "image") { + return { type: "image", image: service.image }; + } + return { + type: "github", + repository: resolveRepository(service, repo), + branch: + repo?.deployBranch?.trim() || + repo?.defaultBranch?.trim() || + service.githubBranch?.trim() || + "main", + ...(service.githubRootDir?.trim() + ? { rootDir: service.githubRootDir.trim() } + : {}), + }; +} + +export async function resolvePersistedSource( + service: NestedService, +): Promise { + if (service.sourceType === "image") { + return { type: "image", image: service.image }; + } + const repo = await db + .select() + .from(githubRepos) + .where(eq(githubRepos.serviceId, service.id)) + .limit(1) + .then((rows) => rows[0]); + return resolvePersistedSourceFromRows(service, repo); +} + +export async function findNestedService( + projectId: string, + environmentId: string, + serviceId: string, +) { + const row = await db + .select({ service: services }) + .from(projects) + .innerJoin( + environments, + and( + eq(environments.id, 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; +} + +export function apiError(message: string, code: string, status: number) { + return Response.json({ message, code }, { status }); +} +export const notFound = () => apiError("Resource not found", "NOT_FOUND", 404); +export const badRequest = (message: string, code = "INVALID_REQUEST") => + apiError(message, code, 400); + +type ManagementBlocker = { code: string; message: string }; +function getManagementBlockers(input: { + service: NestedService; + source: PublicSource; + ports: Array; + volumeCount: number; + replicaCount: number; +}): ManagementBlocker[] { + const blockers: ManagementBlocker[] = []; + if (input.service.stateful || input.volumeCount > 0) { + blockers.push({ + code: "UNSUPPORTED_STATEFUL_OR_VOLUMES", + message: "Stateful services and volumes must be managed in the web UI", + }); + } + if (input.ports.some((port) => port.protocol !== "http")) { + blockers.push({ + code: "UNSUPPORTED_PORT_PROTOCOL", + message: "TCP and UDP ports must be managed in the web UI", + }); + } + if ( + input.ports.some( + (port) => port.externalPort !== null || port.tlsPassthrough, + ) + ) { + blockers.push({ + code: "UNSUPPORTED_PORT_OPTIONS", + message: + "External port allocation and TLS passthrough must be managed in the web UI", + }); + } + if (input.ports.some((port) => port.isPublic && !port.domain)) { + blockers.push({ + code: "UNMANAGED_PUBLIC_PORT", + message: + "Public HTTP ports without domains must be managed in the web UI", + }); + } + if (input.replicaCount < 1 || input.replicaCount > 10) { + blockers.push({ + code: "INVALID_PLACEMENT", + message: "Manual placement must contain between 1 and 10 replicas", + }); + } + if ( + (input.service.resourceCpuLimit === null) !== + (input.service.resourceMemoryLimitMb === null) + ) { + blockers.push({ + code: "INVALID_RESOURCE_LIMITS", + message: "CPU and memory limits must both be set or both be cleared", + }); + } + if (input.source.type === "github") { + if (!input.source.repository) { + blockers.push({ + code: "INCOMPLETE_GITHUB_SOURCE", + message: "Connect the service to a GitHub repository in the web UI", + }); + } + if (input.source.rootDir && !isSafeRepositoryRoot(input.source.rootDir)) { + blockers.push({ + code: "INVALID_GITHUB_ROOT", + message: "The configured GitHub root directory is unsafe", + }); + } + } + return blockers; +} + +function sanitizeSpec(specification: unknown) { + const spec = parseServiceRevisionSpec(specification); + return { + source: + spec.source.type === "github" + ? { + type: "github" as const, + repository: spec.source.repository, + branch: spec.source.branch, + ...(spec.source.rootDir + ? { rootDir: spec.source.rootDir } + : {}), + } + : { type: "image" as const, image: spec.source.image }, + hostname: spec.hostname, + stateful: spec.stateful, + replicas: spec.placements.reduce( + (sum, placement) => sum + placement.count, + 0, + ), + placements: spec.placements, + healthCheck: spec.healthCheck, + startCommand: spec.startCommand, + resources: spec.resourceLimits, + ports: spec.ports.map((port) => ({ + containerPort: port.containerPort, + public: port.isPublic, + domain: port.domain, + protocol: port.protocol, + externalPort: port.externalPort, + tlsPassthrough: port.tlsPassthrough, + })), + volumes: spec.volumes, + serverless: spec.serverless, + }; +} + +export async function safeConfiguration(service: NestedService) { + const [repo, ports, volumes, placements, activeDeployment] = + await Promise.all([ + db + .select() + .from(githubRepos) + .where(eq(githubRepos.serviceId, service.id)) + .limit(1) + .then((rows) => rows[0]), + db + .select() + .from(servicePorts) + .where(eq(servicePorts.serviceId, service.id)), + db + .select({ + name: serviceVolumes.name, + containerPath: serviceVolumes.containerPath, + }) + .from(serviceVolumes) + .where(eq(serviceVolumes.serviceId, service.id)), + db + .select({ + serverId: serviceReplicas.serverId, + serverName: servers.name, + count: serviceReplicas.count, + }) + .from(serviceReplicas) + .innerJoin(servers, eq(servers.id, serviceReplicas.serverId)) + .where(eq(serviceReplicas.serviceId, service.id)), + db + .select({ + id: deployments.id, + revisionId: deployments.serviceRevisionId, + }) + .from(deployments) + .where( + and( + eq(deployments.serviceId, service.id), + eq(deployments.trafficState, "active"), + ), + ) + .orderBy(desc(deployments.createdAt), desc(deployments.id)) + .limit(1) + .then((rows) => rows[0] ?? null), + ]); + + const source = resolvePersistedSourceFromRows(service, repo); + const sortedPlacements = placements.toSorted((a, b) => + a.serverId.localeCompare(b.serverId, "en"), + ); + const sortedPorts = ports.toSorted( + (a, b) => + a.port - b.port || + a.protocol.localeCompare(b.protocol, "en") || + (a.domain ?? "").localeCompare(b.domain ?? "", "en"), + ); + const sortedVolumes = volumes.toSorted( + (a, b) => + a.name.localeCompare(b.name, "en") || + a.containerPath.localeCompare(b.containerPath, "en"), + ); + const replicaCount = sortedPlacements.reduce( + (sum, placement) => sum + placement.count, + 0, + ); + const current = { + source, + hostname: service.hostname, + stateful: service.stateful, + replicas: replicaCount, + placements: sortedPlacements, + healthCheck: service.healthCheckCmd + ? { + cmd: service.healthCheckCmd, + interval: service.healthCheckInterval ?? 10, + timeout: service.healthCheckTimeout ?? 5, + retries: service.healthCheckRetries ?? 3, + startPeriod: service.healthCheckStartPeriod ?? 30, + } + : null, + startCommand: service.startCommand, + resources: { + cpuCores: service.resourceCpuLimit, + memoryMb: service.resourceMemoryLimitMb, + }, + ports: sortedPorts.map((port) => ({ + containerPort: port.port, + public: port.isPublic, + domain: port.domain, + protocol: port.protocol, + externalPort: port.externalPort, + tlsPassthrough: port.tlsPassthrough, + })), + volumes: sortedVolumes, + serverless: { + enabled: service.serverlessEnabled, + sleepAfterSeconds: service.serverlessSleepAfterSeconds, + wakeTimeoutSeconds: service.serverlessWakeTimeoutSeconds, + }, + schedules: { + deployment: service.deploymentSchedule, + backup: { + enabled: service.backupEnabled, + schedule: service.backupSchedule, + }, + }, + }; + + let active: ReturnType | null = null; + if (activeDeployment) { + const revision = await db + .select({ specification: serviceRevisions.specification }) + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.id, activeDeployment.revisionId), + eq(serviceRevisions.serviceId, service.id), + ), + ) + .limit(1) + .then((rows) => rows[0]); + try { + active = revision ? sanitizeSpec(revision.specification) : null; + } catch { + active = null; + } + } + + const comparableCurrent = { + source: current.source, + hostname: + current.hostname?.trim() || getDefaultServiceHostname(service.name), + stateful: current.stateful, + replicas: current.replicas, + placements: current.placements.map(({ serverId, count }) => ({ + serverId, + count, + })), + healthCheck: current.healthCheck, + startCommand: current.startCommand?.trim() || null, + resources: current.resources, + ports: current.ports, + volumes: current.volumes, + serverless: current.serverless, + }; + const pendingChanges = active + ? Object.keys(comparableCurrent).flatMap((field) => + JSON.stringify( + comparableCurrent[field as keyof typeof comparableCurrent], + ) === JSON.stringify(active[field as keyof typeof active]) + ? [] + : [{ field, from: "active revision", to: "current configuration" }], + ) + : [ + { + field: "deployment", + from: "no readable active revision", + to: "current configuration", + }, + ]; + const blockers = getManagementBlockers({ + service, + source, + ports, + volumeCount: volumes.length, + replicaCount, + }); + + return { + current, + active, + activeRevisionId: activeDeployment?.revisionId ?? null, + activeDeploymentId: activeDeployment?.id ?? null, + hasPendingChanges: pendingChanges.length > 0, + changes: pendingChanges, + management: { patchable: blockers.length === 0, blockers }, + }; +} + +const healthCheckSchema = z.strictObject({ + cmd: z.string().trim().min(1).max(2048), + interval: z.number().int().min(1), + timeout: z.number().int().min(1), + retries: z.number().int().min(1), + startPeriod: z.number().int().min(0), +}); +const portSchema = z + .strictObject({ + containerPort: z.number().int().min(1).max(65535), + public: z.boolean(), + domain: z + .string() + .trim() + .min(1) + .max(253) + .transform((value) => value.toLowerCase()) + .nullable() + .optional(), + }) + .superRefine((port, context) => { + if (port.public && !port.domain) { + context.addIssue({ + code: "custom", + path: ["domain"], + message: "Public HTTP ports require a domain", + }); + } + if (!port.public && port.domain) { + context.addIssue({ + code: "custom", + path: ["domain"], + message: "Internal ports cannot define a domain", + }); + } + }); +const hostnameSchema = z + .string() + .trim() + .toLowerCase() + .min(1) + .max(63) + .regex( + /^[a-z0-9]+(?:-[a-z0-9]+)*$/, + "hostname must contain only lowercase letters, numbers, and hyphens", + ); +export const configurationPatchSchema = z.strictObject({ + source: publicSourceSchema.optional(), + hostname: hostnameSchema.nullable().optional(), + ports: z.array(portSchema).max(100).optional(), + replicas: z.number().int().min(1).max(10).optional(), + healthCheck: healthCheckSchema.nullable().optional(), + startCommand: z.string().trim().min(1).max(4096).nullable().optional(), + resources: z + .strictObject({ + cpuCores: z.number().min(0.1).max(64).nullable(), + memoryMb: z.number().int().min(64).max(65536).nullable(), + }) + .refine( + (value) => (value.cpuCores === null) === (value.memoryMb === null), + "CPU and memory limits must both be set or both be null", + ) + .optional(), +}); + +type PublicApiDomainError = Error & { code: string; status: number }; +function domainError(message: string, code: string, status = 409): never { + throw Object.assign(new Error(message), { + code, + status, + }) as PublicApiDomainError; +} +export function isPublicApiDomainError( + error: unknown, +): error is PublicApiDomainError { + const value = error as Partial; + return ( + error instanceof Error && + typeof value.code === "string" && + typeof value.status === "number" + ); +} +export function publicApiDomainResponse(error: PublicApiDomainError) { + return apiError(error.message, error.code, error.status); +} + +function healthCheckFromService(service: NestedService) { + return service.healthCheckCmd + ? { + cmd: service.healthCheckCmd, + interval: service.healthCheckInterval ?? 10, + timeout: service.healthCheckTimeout ?? 5, + retries: service.healthCheckRetries ?? 3, + startPeriod: service.healthCheckStartPeriod ?? 30, + } + : null; +} + +export async function patchConfiguration( + service: NestedService, + input: z.infer, +) { + if (input.source?.type === "image" && input.source.image !== service.image) { + const validation = await validateDockerImageInternal(input.source.image); + if (!validation.valid) { + domainError(validation.error || "Invalid image", "INVALID_IMAGE", 400); + } + } + + return db.transaction(async (tx) => { + const persisted = await tx + .select() + .from(services) + .where(and(eq(services.id, service.id), isNull(services.deletedAt))) + .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() + .from(servicePorts) + .where(eq(servicePorts.serviceId, service.id)), + tx + .select() + .from(serviceVolumes) + .where(eq(serviceVolumes.serviceId, service.id)), + tx + .select() + .from(serviceReplicas) + .where(eq(serviceReplicas.serviceId, service.id)), + tx + .select() + .from(githubRepos) + .where(eq(githubRepos.serviceId, service.id)) + .limit(1) + .then((rows) => rows[0]), + ]); + const replicaCount = placements.reduce( + (sum, placement) => sum + placement.count, + 0, + ); + const source = resolvePersistedSourceFromRows(persisted, repo); + const blockers = getManagementBlockers({ + service: persisted, + source, + ports, + volumeCount: volumes.length, + replicaCount, + }); + if (blockers[0]) { + domainError(blockers[0].message, blockers[0].code); + } + + if (input.source && 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 (source.type !== "github" || !source.repository) { + domainError( + "The service does not have a valid linked GitHub repository", + "INCOMPLETE_GITHUB_SOURCE", + ); + } + if ( + input.source.repository.toLowerCase() !== + source.repository.toLowerCase() + ) { + domainError( + "GitHub repository switching is not supported; relink it in the web UI", + "GITHUB_REPOSITORY_SWITCH", + ); + } + } + if (input.replicas !== undefined && input.replicas !== replicaCount) { + domainError( + `replicas must match the current manual placement of ${replicaCount}`, + "REPLICA_PLACEMENT_MISMATCH", + ); + } + + if (input.hostname) { + const duplicate = 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 (duplicate) { + domainError("Hostname is already in use", "HOSTNAME_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, + ); + } + if ( + new Set(input.ports.map((port) => port.containerPort)).size !== + input.ports.length + ) { + domainError("Port numbers must be unique", "DUPLICATE_PORT", 400); + } + const domains = input.ports.flatMap((port) => + port.public && port.domain ? [port.domain] : [], + ); + if (new Set(domains).size !== domains.length) { + domainError("Port domains must be unique", "DUPLICATE_DOMAIN", 400); + } + 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"); + } + } + } + + const changes: string[] = []; + const set: Partial = {}; + const changed = (label: string, from: unknown, to: unknown) => { + if (JSON.stringify(from) === JSON.stringify(to)) return false; + changes.push(label); + return true; + }; + + if ( + input.hostname !== undefined && + changed("hostname", persisted.hostname, input.hostname) + ) { + set.hostname = input.hostname; + } + if ( + input.startCommand !== undefined && + changed("startCommand", persisted.startCommand, input.startCommand) + ) { + set.startCommand = input.startCommand; + } + if ( + input.healthCheck !== undefined && + changed( + "healthCheck", + healthCheckFromService(persisted), + input.healthCheck, + ) + ) { + Object.assign( + set, + input.healthCheck + ? { + healthCheckCmd: input.healthCheck.cmd, + healthCheckInterval: input.healthCheck.interval, + healthCheckTimeout: input.healthCheck.timeout, + healthCheckRetries: input.healthCheck.retries, + healthCheckStartPeriod: input.healthCheck.startPeriod, + } + : { + healthCheckCmd: null, + healthCheckInterval: null, + healthCheckTimeout: null, + healthCheckRetries: null, + healthCheckStartPeriod: null, + }, + ); + } + if ( + input.resources !== undefined && + changed( + "resources", + { + cpuCores: persisted.resourceCpuLimit, + memoryMb: persisted.resourceMemoryLimitMb, + }, + input.resources, + ) + ) { + set.resourceCpuLimit = input.resources.cpuCores; + set.resourceMemoryLimitMb = input.resources.memoryMb; + } + if ( + input.source?.type === "image" && + changed("source.image", persisted.image, input.source.image) + ) { + set.image = input.source.image; + } + if (input.source?.type === "github") { + const effectiveBranch = + repo?.deployBranch || + repo?.defaultBranch || + persisted.githubBranch || + "main"; + if (changed("source.branch", effectiveBranch, input.source.branch)) { + set.githubBranch = input.source.branch; + if (repo) { + await tx + .update(githubRepos) + .set({ deployBranch: input.source.branch }) + .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; + } + } + } + + if (Object.keys(set).length > 0) { + await tx.update(services).set(set).where(eq(services.id, service.id)); + } + if (input.ports) { + const currentPorts = ports + .map((port) => [port.port, port.isPublic, port.domain] as const) + .toSorted((a, b) => a[0] - b[0]); + const desiredPorts = input.ports + .map( + (port) => + [port.containerPort, port.public, port.domain ?? null] as const, + ) + .toSorted((a, b) => a[0] - b[0]); + if (changed("ports", currentPorts, desiredPorts)) { + await tx + .delete(servicePorts) + .where(eq(servicePorts.serviceId, service.id)); + if (input.ports.length > 0) { + await tx.insert(servicePorts).values( + input.ports.map((port) => ({ + id: randomUUID(), + serviceId: service.id, + port: port.containerPort, + isPublic: port.public, + domain: port.public ? (port.domain ?? null) : null, + protocol: "http" as const, + })), + ); + } + } + } + + return { + action: changes.length > 0 ? ("updated" as const) : ("noop" as const), + changes, + }; + }); +} diff --git a/web/lib/scheduler.ts b/web/lib/scheduler.ts index 803cbdcf..d7c31ea0 100644 --- a/web/lib/scheduler.ts +++ b/web/lib/scheduler.ts @@ -1,6 +1,5 @@ import { CronExpressionParser } from "cron-parser"; import { and, eq, inArray, isNotNull, isNull, lt, ne, sql } from "drizzle-orm"; -import { triggerBuild } from "@/actions/builds"; import { db } from "@/db"; import { deployments, @@ -213,11 +212,13 @@ export async function checkAndRunScheduledDeployments(): Promise { `[scheduler] triggering scheduled deployment for ${service.name} (sourceType=${service.sourceType})`, ); - if (service.sourceType === "github") { - await triggerBuild(service.id, "scheduled"); - } else { - await deployServiceInternal(service.id, { type: "system" }); - } + await deployServiceInternal( + service.id, + { type: "system" }, + { + githubTrigger: "scheduled", + }, + ); console.log( `[scheduler] ${service.name}: deployment triggered successfully`, diff --git a/web/lib/service-config.ts b/web/lib/service-config.ts index eafe1d78..62627391 100644 --- a/web/lib/service-config.ts +++ b/web/lib/service-config.ts @@ -22,10 +22,17 @@ export type HealthCheckConfig = { startPeriod: number; }; -export type SourceConfig = { - type: "image"; - image: string; -}; +export type SourceConfig = + | { + type: "image"; + image: string; + } + | { + type: "github"; + repository: string | null; + branch: string; + rootDir: string | null; + }; export type SecretConfig = { key: string; @@ -77,13 +84,16 @@ export type ConfigChange = { from: string; to: string; secretKey?: string; + requiresBuild?: boolean; }; export const TECHULUS_DOCKERFILE_PATH = "TECHULUS_DOCKERFILE_PATH"; export function hasBuildAffectingChanges(changes: ConfigChange[]): boolean { return changes.some( - (change) => change.secretKey === TECHULUS_DOCKERFILE_PATH, + (change) => + change.secretKey === TECHULUS_DOCKERFILE_PATH || + change.requiresBuild === true, ); } @@ -113,17 +123,15 @@ export function buildCurrentConfig( protocol?: "http" | "tcp" | "udp" | null; tlsPassthrough?: boolean | null; }[], - secrets?: { key: string; updatedAt: Date | string }[], - volumes?: { name: string; containerPath: string }[], + secrets: { key: string; updatedAt: Date | string }[] | undefined, + volumes: { name: string; containerPath: string }[] | undefined, + source: SourceConfig, ): DeployedConfig { const hasResourceLimits = service.resourceCpuLimit != null || service.resourceMemoryLimitMb != null; return { - source: { - type: "image", - image: service.image, - }, + source, hostname: service.hostname ?? undefined, stateful: service.stateful ?? false, placement: { @@ -177,12 +185,19 @@ export function diffConfigs( const changes: ConfigChange[] = []; if (!deployed) { - if (current.source.image) { + if (current.source.type === "image" && current.source.image) { changes.push({ field: "Image", from: "(not deployed)", to: current.source.image, }); + } else if (current.source.type === "github") { + changes.push({ + field: "GitHub repository", + from: "(not deployed)", + to: current.source.repository ?? "(not configured)", + requiresBuild: true, + }); } for (const replica of current.replicas) { changes.push({ @@ -300,12 +315,54 @@ export function diffConfigs( } } - if (deployed.source.image !== current.source.image) { + if ( + deployed.source.type === "image" && + current.source.type === "image" && + deployed.source.image !== current.source.image + ) { changes.push({ field: "Image", from: deployed.source.image, to: current.source.image, }); + } else if ( + deployed.source.type === "github" && + current.source.type === "github" + ) { + if ( + (deployed.source.repository ?? "").toLowerCase() !== + (current.source.repository ?? "").toLowerCase() + ) { + changes.push({ + field: "GitHub repository", + from: deployed.source.repository ?? "(not configured)", + to: current.source.repository ?? "(not configured)", + requiresBuild: true, + }); + } + if (deployed.source.branch !== current.source.branch) { + changes.push({ + field: "GitHub branch", + from: deployed.source.branch, + to: current.source.branch, + requiresBuild: true, + }); + } + if (deployed.source.rootDir !== current.source.rootDir) { + changes.push({ + field: "GitHub root directory", + from: deployed.source.rootDir ?? "(repository root)", + to: current.source.rootDir ?? "(repository root)", + requiresBuild: true, + }); + } + } else if (deployed.source.type !== current.source.type) { + changes.push({ + field: "Source", + from: deployed.source.type === "github" ? "GitHub" : "Image", + to: current.source.type === "github" ? "GitHub" : "Image", + requiresBuild: current.source.type === "github", + }); } if (deployed.hostname !== current.hostname) { @@ -589,7 +646,15 @@ export function revisionSpecToDeployedConfig( serverNames: Record, ): DeployedConfig { return { - source: { type: "image", image: specification.image }, + source: + specification.source.type === "github" + ? { + type: "github", + repository: specification.source.repository, + branch: specification.source.branch, + rootDir: specification.source.rootDir, + } + : { type: "image", image: specification.source.image }, hostname: specification.hostname, stateful: specification.stateful, placement: { diff --git a/web/lib/service-revision-changelog.ts b/web/lib/service-revision-changelog.ts new file mode 100644 index 00000000..f0ea306e --- /dev/null +++ b/web/lib/service-revision-changelog.ts @@ -0,0 +1,158 @@ +import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { rollouts, servers, serviceRevisions } from "@/db/schema"; +import { + encodeTimestampCursor, + type TimestampCursor, +} from "@/lib/public-api-pagination"; +import { sanitizeServiceRevisionActor } from "@/lib/service-revision-actor"; +import { + diffServiceRevisionSpecs, + parseServiceRevisionSpec, + type ServiceRevisionChangelogItem, + type ServiceRevisionChangelogResponse, +} from "@/lib/service-revision-changes"; +import type { ServiceRevisionSpec } from "@/lib/service-revision-spec"; + +const PAGE_SIZE = 25; + +/** Load revision history after the caller has authenticated and scoped the service. */ +export async function queryServiceRevisionChangelog( + serviceId: string, + cursor?: TimestampCursor, +): Promise { + const revisions = await db + .select({ + id: serviceRevisions.id, + createdAt: serviceRevisions.createdAt, + cursorCreatedAt: sql`${serviceRevisions.createdAt}::text`, + specification: serviceRevisions.specification, + actor: serviceRevisions.actor, + }) + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.serviceId, serviceId), + cursor + ? or( + lt( + serviceRevisions.createdAt, + sql`${cursor.createdAt}::timestamptz`, + ), + and( + eq( + serviceRevisions.createdAt, + sql`${cursor.createdAt}::timestamptz`, + ), + lt(serviceRevisions.id, cursor.id), + ), + ) + : undefined, + ), + ) + .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id)) + .limit(PAGE_SIZE + 1); + + const pageRevisions = revisions.slice(0, PAGE_SIZE); + const revisionIds = pageRevisions.map((revision) => revision.id); + const parsedSpecifications = new Map(); + for (const revision of revisions) { + try { + parsedSpecifications.set( + revision.id, + parseServiceRevisionSpec(revision.specification), + ); + } catch { + // Unsupported or malformed historical revisions remain visible. + } + } + const placementServerIds = [ + ...new Set( + [...parsedSpecifications.values()].flatMap((specification) => + specification.placements.map((placement) => placement.serverId), + ), + ), + ]; + const [serverRows, revisionRollouts] = await Promise.all([ + placementServerIds.length > 0 + ? db + .select({ id: servers.id, name: servers.name }) + .from(servers) + .where(inArray(servers.id, placementServerIds)) + : Promise.resolve([]), + revisionIds.length > 0 + ? db + .select({ + id: rollouts.id, + serviceRevisionId: rollouts.serviceRevisionId, + status: rollouts.status, + createdAt: rollouts.createdAt, + }) + .from(rollouts) + .where( + and( + eq(rollouts.serviceId, serviceId), + inArray(rollouts.serviceRevisionId, revisionIds), + ), + ) + .orderBy(desc(rollouts.createdAt), desc(rollouts.id)) + : Promise.resolve([]), + ]); + const serverNames = new Map( + serverRows.map((server) => [server.id, server.name] as const), + ); + const rolloutByRevisionId = new Map< + string, + (typeof revisionRollouts)[number] + >(); + for (const rollout of revisionRollouts) { + if (!rolloutByRevisionId.has(rollout.serviceRevisionId)) { + rolloutByRevisionId.set(rollout.serviceRevisionId, rollout); + } + } + + const items: ServiceRevisionChangelogItem[] = pageRevisions.map( + (revision, index) => { + const previous = revisions[index + 1]; + let comparison: ServiceRevisionChangelogItem["comparison"]; + if (!previous) { + comparison = { kind: "initial" }; + } else { + const currentSpecification = parsedSpecifications.get(revision.id); + const previousSpecification = parsedSpecifications.get(previous.id); + if (currentSpecification && previousSpecification) { + comparison = { + kind: "changes", + changes: diffServiceRevisionSpecs( + previousSpecification, + currentSpecification, + serverNames, + ), + }; + } else { + comparison = { kind: "unavailable" }; + } + } + + const rollout = rolloutByRevisionId.get(revision.id); + return { + id: revision.id, + createdAt: revision.createdAt.toISOString(), + actor: sanitizeServiceRevisionActor(revision.actor), + comparison, + rollout: rollout ? { id: rollout.id, status: rollout.status } : null, + }; + }, + ); + + return { + revisions: items, + nextCursor: + revisions.length > PAGE_SIZE && pageRevisions.length > 0 + ? encodeTimestampCursor({ + createdAt: pageRevisions[pageRevisions.length - 1].cursorCreatedAt, + id: pageRevisions[pageRevisions.length - 1].id, + }) + : null, + }; +} diff --git a/web/lib/service-revision-changes.ts b/web/lib/service-revision-changes.ts index 752bb8e9..5e3cd41f 100644 --- a/web/lib/service-revision-changes.ts +++ b/web/lib/service-revision-changes.ts @@ -8,8 +8,26 @@ import type { } from "@/lib/service-revision-spec"; const serviceRevisionSpecSchema = z.strictObject({ - schemaVersion: z.literal(1), + schemaVersion: z.literal(2), image: z.string(), + source: z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("image"), image: z.string() }), + z.strictObject({ + type: z.literal("github"), + repository: z.string().url(), + repositoryId: z.number().int().positive().nullable(), + branch: z.string().min(1), + commitSha: z.string().regex(/^[0-9a-f]{40}$/), + rootDir: z.string().min(1).nullable(), + authentication: z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("anonymous") }), + z.strictObject({ + type: z.literal("github_app"), + installationId: z.number().int().positive(), + }), + ]), + }), + ]), hostname: z.string(), stateful: z.boolean(), serverless: z.strictObject({ @@ -120,7 +138,7 @@ function portDescription(port: ServiceRevisionPort): string { ].join(", "); } -/** Compare two immutable v1 specifications without requiring browser APIs. */ +/** Compare two immutable v2 specifications without requiring browser APIs. */ export function diffServiceRevisionSpecs( previous: ServiceRevisionSpec, current: ServiceRevisionSpec, @@ -132,6 +150,21 @@ export function diffServiceRevisionSpecs( }; add("Image", previous.image, current.image); + add("Source type", previous.source.type, current.source.type); + if (previous.source.type === "github" && current.source.type === "github") { + add( + "GitHub repository", + previous.source.repository, + current.source.repository, + ); + add("GitHub branch", previous.source.branch, current.source.branch); + add("GitHub commit", previous.source.commitSha, current.source.commitSha); + add( + "GitHub root directory", + previous.source.rootDir ?? "(repository root)", + current.source.rootDir ?? "(repository root)", + ); + } add("Hostname", previous.hostname, current.hostname); add( "Service type", diff --git a/web/lib/service-revision-spec.ts b/web/lib/service-revision-spec.ts index a8f7aeed..3e44c3e7 100644 --- a/web/lib/service-revision-spec.ts +++ b/web/lib/service-revision-spec.ts @@ -1,4 +1,4 @@ -export const SERVICE_REVISION_SCHEMA_VERSION = 1 as const; +export const SERVICE_REVISION_SCHEMA_VERSION = 2 as const; export function getDefaultServiceHostname(name: string): string { return name @@ -40,9 +40,27 @@ export type ServiceRevisionVolume = { containerPath: string; }; +export type ServiceRevisionSource = + | { + type: "image"; + image: string; + } + | { + type: "github"; + repository: string; + repositoryId: number | null; + branch: string; + commitSha: string; + rootDir: string | null; + authentication: + | { type: "anonymous" } + | { type: "github_app"; installationId: number }; + }; + export type ServiceRevisionSpec = { schemaVersion: typeof SERVICE_REVISION_SCHEMA_VERSION; image: string; + source: ServiceRevisionSource; hostname: string; stateful: boolean; serverless: { @@ -97,13 +115,22 @@ export type ServiceRevisionDraft = { volumes: Array<{ name: string; containerPath: string }>; }; -function validateServiceRevisionSpec(specification: ServiceRevisionSpec) { +export type ServiceRevisionSpecOverrides = { + image?: string; + source?: ServiceRevisionSource; + allowNoPlacements?: boolean; +}; + +function validateServiceRevisionSpec( + specification: ServiceRevisionSpec, + allowNoPlacements: boolean, +) { const totalReplicas = specification.placements.reduce( (sum, placement) => sum + placement.count, 0, ); - if (totalReplicas < 1) { + if (totalReplicas < 1 && !allowNoPlacements) { throw new Error("At least one replica is required"); } if (totalReplicas > 10) { @@ -115,6 +142,17 @@ function validateServiceRevisionSpec(specification: ServiceRevisionSpec) { if (specification.stateful && specification.placements.length !== 1) { throw new Error("Stateful services must be deployed to exactly one server"); } + if ( + specification.serverless.enabled && + !specification.ports.some( + (port) => + port.isPublic && port.protocol === "http" && port.domain !== null, + ) + ) { + throw new Error( + "Serverless services require a public HTTP port with a domain", + ); + } } function compareStrings(a: string, b: string) { @@ -123,12 +161,15 @@ function compareStrings(a: string, b: string) { export function buildServiceRevisionSpec( draft: ServiceRevisionDraft, + overrides: ServiceRevisionSpecOverrides = {}, ): ServiceRevisionSpec { const { service } = draft; + const image = overrides.image?.trim() || service.image.trim(); const specification: ServiceRevisionSpec = { schemaVersion: SERVICE_REVISION_SCHEMA_VERSION, - image: service.image.trim(), + image, + source: overrides.source ?? { type: "image", image }, hostname: service.hostname?.trim() || getDefaultServiceHostname(service.name), stateful: service.stateful ?? false, @@ -198,6 +239,9 @@ export function buildServiceRevisionSpec( compareStrings(a.containerPath, b.containerPath), ), }; - validateServiceRevisionSpec(specification); + validateServiceRevisionSpec( + specification, + overrides.allowNoPlacements ?? false, + ); return specification; } diff --git a/web/lib/service-revisions.ts b/web/lib/service-revisions.ts index afd6cb13..cc05aa31 100644 --- a/web/lib/service-revisions.ts +++ b/web/lib/service-revisions.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { and, eq, isNull } from "drizzle-orm"; import { db } from "@/db"; import { + githubRepos, rollouts, secrets, servicePorts, @@ -10,71 +11,347 @@ import { services, serviceVolumes, } from "@/db/schema"; +import { resolvePersistedSourceFromRows } from "@/lib/public-api"; import type { ServiceRevisionActor } from "@/lib/service-revision-actor"; +import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; import { buildServiceRevisionSpec, SERVICE_REVISION_SCHEMA_VERSION, + type ServiceRevisionSource, + type ServiceRevisionSpec, + type ServiceRevisionSpecOverrides, } from "@/lib/service-revision-spec"; -export async function createRolloutWithServiceRevision( - serviceId: string, - actor: ServiceRevisionActor | null, +type RevisionTransaction = Parameters[0]>[0]; + +function specificationsMatch( + actual: unknown, + expected: ServiceRevisionSpec, +): boolean { + try { + return ( + JSON.stringify(parseServiceRevisionSpec(actual)) === + JSON.stringify(expected) + ); + } catch { + return false; + } +} + +function assertMatchingGitHubBuildRevision( + revision: typeof serviceRevisions.$inferSelect, + input: { + serviceId: string; + image: string; + commitSha: string; + expectedRepository: string; + expectedBranch: string; + }, +) { + if (revision.serviceId !== input.serviceId) { + throw new Error("Service revision idempotency conflict"); + } + try { + const specification = parseServiceRevisionSpec(revision.specification); + if ( + specification.image !== input.image || + specification.source.type !== "github" || + specification.source.repository !== input.expectedRepository || + specification.source.branch !== input.expectedBranch || + specification.source.commitSha !== input.commitSha.toLowerCase() + ) { + throw new Error("Service revision idempotency conflict"); + } + } catch (error) { + if ( + error instanceof Error && + error.message === "Service revision idempotency conflict" + ) { + throw error; + } + throw new Error("Service revision idempotency conflict"); + } +} + +function imageRepository(image: string): string { + const digestIndex = image.indexOf("@"); + if (digestIndex > 0) return image.slice(0, digestIndex); + const lastColon = image.lastIndexOf(":"); + return lastColon > image.lastIndexOf("/") ? image.slice(0, lastColon) : image; +} + +async function createServiceRevisionSnapshot( + tx: RevisionTransaction, + input: { + id: string; + serviceId: string; + actor: ServiceRevisionActor | null; + overrides?: ServiceRevisionSpecOverrides; + }, ) { + const existing = await tx + .select() + .from(serviceRevisions) + .where(eq(serviceRevisions.id, input.id)) + .then((rows) => rows[0]); + if (existing) { + if (existing.serviceId !== input.serviceId) { + throw new Error("Service revision idempotency conflict"); + } + return existing; + } + + const service = await tx + .select() + .from(services) + .where(and(eq(services.id, input.serviceId), isNull(services.deletedAt))) + .then((rows) => rows[0]); + + if (!service) throw new Error("Service not found"); + + const [placements, ports, revisionSecrets, volumes] = await Promise.all([ + tx + .select({ + serverId: serviceReplicas.serverId, + count: serviceReplicas.count, + }) + .from(serviceReplicas) + .where(eq(serviceReplicas.serviceId, input.serviceId)), + tx + .select() + .from(servicePorts) + .where(eq(servicePorts.serviceId, input.serviceId)), + tx + .select({ + key: secrets.key, + encryptedValue: secrets.encryptedValue, + updatedAt: secrets.updatedAt, + }) + .from(secrets) + .where(eq(secrets.serviceId, input.serviceId)), + tx + .select({ + name: serviceVolumes.name, + containerPath: serviceVolumes.containerPath, + }) + .from(serviceVolumes) + .where(eq(serviceVolumes.serviceId, input.serviceId)), + ]); + + const specification = buildServiceRevisionSpec( + { + service, + placements, + ports, + secrets: revisionSecrets, + volumes, + }, + input.overrides, + ); + const revision = await tx + .insert(serviceRevisions) + .values({ + id: input.id, + serviceId: input.serviceId, + specification, + actor: input.actor, + }) + .onConflictDoNothing({ target: serviceRevisions.id }) + .returning() + .then((rows) => rows[0]); + + if (revision) return revision; + + const conflictingRevision = await tx + .select() + .from(serviceRevisions) + .where(eq(serviceRevisions.id, input.id)) + .then((rows) => rows[0]); + if ( + !conflictingRevision || + conflictingRevision.serviceId !== input.serviceId || + !specificationsMatch(conflictingRevision.specification, specification) + ) { + throw new Error("Service revision idempotency conflict"); + } + return conflictingRevision; +} + +export async function createGitHubBuildServiceRevision(input: { + id: string; + serviceId: string; + image: string; + commitSha: string; + expectedRepository: string; + expectedBranch: string; + actor: ServiceRevisionActor | null; +}) { return db.transaction( async (tx) => { - const service = await tx + const existing = await tx .select() - .from(services) - .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .from(serviceRevisions) + .where(eq(serviceRevisions.id, input.id)) .then((rows) => rows[0]); + if (existing) { + assertMatchingGitHubBuildRevision(existing, input); + return existing; + } - if (!service) { - throw new Error("Service not found"); + const [service, repo] = await Promise.all([ + tx + .select() + .from(services) + .where( + and(eq(services.id, input.serviceId), isNull(services.deletedAt)), + ) + .then((rows) => rows[0]), + tx + .select() + .from(githubRepos) + .where(eq(githubRepos.serviceId, input.serviceId)) + .then((rows) => rows[0]), + ]); + if (!service || service.sourceType !== "github") { + throw new Error("Active GitHub service not found"); } - const placements = await tx - .select({ - serverId: serviceReplicas.serverId, - count: serviceReplicas.count, - }) - .from(serviceReplicas) - .where(eq(serviceReplicas.serviceId, serviceId)); - const ports = await tx - .select() - .from(servicePorts) - .where(eq(servicePorts.serviceId, serviceId)); - const revisionSecrets = await tx - .select({ - key: secrets.key, - encryptedValue: secrets.encryptedValue, - updatedAt: secrets.updatedAt, - }) - .from(secrets) - .where(eq(secrets.serviceId, serviceId)); - const volumes = await tx - .select({ - name: serviceVolumes.name, - containerPath: serviceVolumes.containerPath, - }) - .from(serviceVolumes) - .where(eq(serviceVolumes.serviceId, serviceId)); - - const specification = buildServiceRevisionSpec({ - service, - placements, - ports, - secrets: revisionSecrets, - volumes, - }); - const revision = await tx - .insert(serviceRevisions) - .values({ id: randomUUID(), serviceId, specification, actor }) - .returning() - .then((rows) => rows[0]); - if (!revision) { - throw new Error("Failed to create service revision"); + const currentSource = resolvePersistedSourceFromRows(service, repo); + if ( + currentSource.type !== "github" || + !currentSource.repository || + currentSource.repository !== input.expectedRepository || + currentSource.branch !== input.expectedBranch + ) { + throw new Error( + "GitHub source changed while resolving the build commit", + ); } + const source: ServiceRevisionSource = { + type: "github", + repository: currentSource.repository, + repositoryId: repo?.repoId ?? null, + branch: currentSource.branch, + commitSha: input.commitSha, + rootDir: currentSource.rootDir?.trim() || null, + authentication: repo + ? { + type: "github_app", + installationId: repo.installationId, + } + : { type: "anonymous" }, + }; + + return createServiceRevisionSnapshot(tx, { + id: input.id, + serviceId: input.serviceId, + actor: input.actor, + overrides: { + image: input.image, + source, + allowNoPlacements: true, + }, + }); + }, + { isolationLevel: "repeatable read" }, + ); +} + +export async function cloneGitHubBuildServiceRevision(input: { + serviceId: string; + sourceRevisionId: string; + actor: ServiceRevisionActor | null; +}) { + return db.transaction(async (tx) => { + const [sourceRevision, activeService] = await Promise.all([ + tx + .select() + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.id, input.sourceRevisionId), + eq(serviceRevisions.serviceId, input.serviceId), + ), + ) + .then((rows) => rows[0]), + tx + .select({ id: services.id }) + .from(services) + .where( + and(eq(services.id, input.serviceId), isNull(services.deletedAt)), + ) + .then((rows) => rows[0]), + ]); + if (!sourceRevision) throw new Error("Build service revision not found"); + if (!activeService) throw new Error("Active service not found"); + + const sourceSpecification = parseServiceRevisionSpec( + sourceRevision.specification, + ); + if (sourceSpecification.source.type !== "github") { + throw new Error("Build service revision is not a GitHub revision"); + } + + const id = randomUUID(); + const specification: ServiceRevisionSpec = { + ...sourceSpecification, + image: `${imageRepository(sourceSpecification.image)}:revision-${id}`, + }; + const revision = await tx + .insert(serviceRevisions) + .values({ + id, + serviceId: input.serviceId, + specification, + actor: input.actor, + }) + .returning() + .then((rows) => rows[0]); + if (!revision) throw new Error("Failed to create retry service revision"); + return revision; + }); +} + +export async function createRolloutWithServiceRevision( + serviceId: string, + actor: ServiceRevisionActor | null, + runtimeBaseRevisionId?: string, +) { + return db.transaction( + async (tx) => { + let overrides: ServiceRevisionSpecOverrides | undefined; + if (runtimeBaseRevisionId) { + const baseRevision = await tx + .select({ specification: serviceRevisions.specification }) + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.id, runtimeBaseRevisionId), + eq(serviceRevisions.serviceId, serviceId), + ), + ) + .then((rows) => rows[0]); + if (!baseRevision) { + throw new Error("Runtime base service revision not found"); + } + const baseSpecification = parseServiceRevisionSpec( + baseRevision.specification, + ); + if (baseSpecification.source.type !== "github") { + throw new Error("GitHub runtime base revision is not a GitHub build"); + } + overrides = { + image: baseSpecification.image, + source: baseSpecification.source, + }; + } + const revision = await createServiceRevisionSnapshot(tx, { + id: randomUUID(), + serviceId, + actor, + overrides, + }); const rolloutId = randomUUID(); await tx.insert(rollouts).values({ id: rolloutId, @@ -83,13 +360,73 @@ export async function createRolloutWithServiceRevision( status: "queued", currentStage: "queued", }); - return { rolloutId, revision }; }, { isolationLevel: "repeatable read" }, ); } +export async function createRolloutForServiceRevision( + serviceId: string, + serviceRevisionId: string, + artifactImageUri: string, +) { + return db.transaction(async (tx) => { + const [revision, activeService] = await Promise.all([ + tx + .select() + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.id, serviceRevisionId), + eq(serviceRevisions.serviceId, serviceId), + ), + ) + .then((rows) => rows[0]), + tx + .select({ id: services.id }) + .from(services) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .then((rows) => rows[0]), + ]); + if (!revision) throw new Error("Service revision not found"); + if (!activeService) { + return { rolloutId: null, revision, created: false }; + } + + const specification = parseServiceRevisionSpec(revision.specification); + if (specification.image !== artifactImageUri) { + throw new Error("Built artifact does not match the service revision"); + } + if (!specification.placements.some((placement) => placement.count > 0)) { + return { rolloutId: null, revision, created: false }; + } + + const rolloutId = randomUUID(); + const created = await tx + .insert(rollouts) + .values({ + id: rolloutId, + serviceId, + serviceRevisionId, + status: "queued", + currentStage: "queued", + }) + .onConflictDoNothing({ target: rollouts.serviceRevisionId }) + .returning({ id: rollouts.id }) + .then((rows) => rows[0]); + if (created) return { rolloutId: created.id, revision, created: true }; + + const existing = await tx + .select({ id: rollouts.id }) + .from(rollouts) + .where(eq(rollouts.serviceRevisionId, serviceRevisionId)) + .then((rows) => rows[0]); + if (!existing) throw new Error("Failed to create rollout"); + return { rolloutId: existing.id, revision, created: false }; + }); +} + export async function getRolloutServiceRevision(rolloutId: string) { const result = await db .select({ @@ -103,9 +440,7 @@ export async function getRolloutServiceRevision(rolloutId: string) { .where(eq(rollouts.id, rolloutId)) .then((rows) => rows[0]); - if (!result) { - throw new Error("Rollout revision not found"); - } + if (!result) throw new Error("Rollout revision not found"); if ( result.revision.specification.schemaVersion !== SERVICE_REVISION_SCHEMA_VERSION diff --git a/web/lib/trigger-build.ts b/web/lib/trigger-build.ts new file mode 100644 index 00000000..3350db15 --- /dev/null +++ b/web/lib/trigger-build.ts @@ -0,0 +1,211 @@ +import { createHash, randomUUID } from "node:crypto"; +import { and, eq, isNull } from "drizzle-orm"; +import { db } from "@/db"; +import { githubRepos, services } from "@/db/schema"; +import { resolveGitHubCommit } from "@/lib/github"; +import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; +import { + canonicalGitHubRepository, + resolvePersistedSourceFromRows, +} from "@/lib/public-api"; +import type { ServiceRevisionActor } from "@/lib/service-revision-actor"; +import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; +import { + cloneGitHubBuildServiceRevision, + createGitHubBuildServiceRevision, +} from "@/lib/service-revisions"; + +type BuildTrigger = "manual" | "scheduled" | "push"; +const fullCommitSha = /^[0-9a-f]{40}$/i; + +type ResolvedBuildInput = { + trigger: BuildTrigger; + commitSha: string; + commitMessage: string; + author?: string; + actor: ServiceRevisionActor; + expectedRepository?: string; + expectedBranch?: string; + githubDeploymentId?: number; + idempotencyKey?: string; +}; + +function deterministicRevisionId(key: string): string { + const hash = createHash("sha256").update(key).digest("hex"); + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-5${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`; +} + +async function getGitHubBuildSource(serviceId: string) { + const [service, repo] = await Promise.all([ + db + .select() + .from(services) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .limit(1) + .then((rows) => rows[0]), + db + .select() + .from(githubRepos) + .where(eq(githubRepos.serviceId, serviceId)) + .limit(1) + .then((rows) => rows[0]), + ]); + if (!service || service.sourceType !== "github") { + throw new Error("Active GitHub service not found"); + } + const source = resolvePersistedSourceFromRows(service, repo); + if (source.type !== "github" || !source.repository) { + throw new Error("No GitHub repository linked to this service"); + } + return { + service, + repo, + source: { ...source, repository: source.repository }, + }; +} + +async function sendBuildTrigger( + data: Parameters[0], + eventId?: string, +) { + const event = eventId + ? inngestEvents.buildTrigger.create(data, { id: eventId }) + : inngestEvents.buildTrigger.create(data); + await inngest.send(event); +} + +export async function triggerResolvedBuildInternal( + serviceId: string, + input: ResolvedBuildInput, +) { + if (!fullCommitSha.test(input.commitSha)) { + throw new Error("Commit SHA must be a full 40-character hexadecimal SHA"); + } + const sourceContext = await getGitHubBuildSource(serviceId); + return queueResolvedBuild(serviceId, input, sourceContext); +} + +async function queueResolvedBuild( + serviceId: string, + input: ResolvedBuildInput, + { service, source }: Awaited>, +) { + if (!fullCommitSha.test(input.commitSha)) { + throw new Error("Commit SHA must be a full 40-character hexadecimal SHA"); + } + const commitSha = input.commitSha.toLowerCase(); + const expectedRepository = input.expectedRepository + ? canonicalGitHubRepository(input.expectedRepository) + : source.repository; + const expectedBranch = input.expectedBranch ?? source.branch; + if ( + source.repository !== expectedRepository || + source.branch !== expectedBranch + ) { + throw new Error("GitHub source changed before the build was queued"); + } + + const registryHost = process.env.REGISTRY_HOST?.replace(/\/+$/, ""); + if (!registryHost) { + throw new Error("REGISTRY_HOST environment variable is required"); + } + const serviceRevisionId = input.idempotencyKey + ? deterministicRevisionId(input.idempotencyKey) + : randomUUID(); + const image = `${registryHost}/${service.projectId}/${service.id}:revision-${serviceRevisionId}`; + + await createGitHubBuildServiceRevision({ + id: serviceRevisionId, + serviceId, + image, + commitSha, + expectedRepository, + expectedBranch, + actor: input.actor, + }); + + const buildRequestId = randomUUID(); + await sendBuildTrigger( + { + serviceId, + serviceRevisionId, + buildRequestId, + trigger: input.trigger, + commitSha, + commitMessage: input.commitMessage.substring(0, 500), + branch: expectedBranch, + author: input.author, + actor: input.actor, + githubDeploymentId: input.githubDeploymentId, + }, + input.idempotencyKey, + ); + + return { buildId: null, serviceRevisionId, status: "queued" as const }; +} + +export async function requeueBuildRevisionInternal(input: { + serviceId: string; + serviceRevisionId: string; + commitMessage: string; + author?: string; + actor: ServiceRevisionActor; +}) { + const revision = await cloneGitHubBuildServiceRevision({ + serviceId: input.serviceId, + sourceRevisionId: input.serviceRevisionId, + actor: input.actor, + }); + const specification = parseServiceRevisionSpec(revision.specification); + if (specification.source.type !== "github") + throw new Error("Invalid retry revision"); + + const buildRequestId = randomUUID(); + await sendBuildTrigger({ + serviceId: input.serviceId, + serviceRevisionId: revision.id, + buildRequestId, + trigger: "manual", + commitSha: specification.source.commitSha, + commitMessage: input.commitMessage.substring(0, 500), + branch: specification.source.branch, + author: input.author, + actor: input.actor, + }); + return { + status: "queued" as const, + serviceRevisionId: revision.id, + buildRequestId, + }; +} + +export async function triggerBuildInternal( + serviceId: string, + trigger: "manual" | "scheduled", + actor: ServiceRevisionActor, +) { + const sourceContext = await getGitHubBuildSource(serviceId); + const { repo, source } = sourceContext; + const repoFullName = + repo?.repoFullName ?? + new URL(source.repository).pathname.replace(/^\//, ""); + const commit = await resolveGitHubCommit( + repoFullName, + source.branch, + repo?.installationId, + ); + return queueResolvedBuild( + serviceId, + { + trigger, + commitSha: commit.sha, + commitMessage: commit.message, + author: commit.author ?? undefined, + actor, + expectedRepository: source.repository, + expectedBranch: source.branch, + }, + sourceContext, + ); +} diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts index 784d6f13..075f8fa7 100644 --- a/web/lib/victoria-logs.ts +++ b/web/lib/victoria-logs.ts @@ -1,4 +1,5 @@ import { + DEFAULT_LOG_TIME_RANGE, escapeLogRegex, type LogTimeRange, normalizeLogCursor, @@ -45,6 +46,7 @@ type LogSearchField = "_msg" | "path" | "method" | "status" | "client_ip"; export type StoredLog = { _msg: string; _time: string; + event_id?: string; deployment_id?: string; service_id: string; server_id?: string; @@ -59,6 +61,23 @@ export type StoredLog = { client_ip?: string; }; +const publicServiceLogEventIdPattern = /^e[0-9]{19}[a-z]{26}$/; + +export function isPublicServiceLogEventId(value: unknown): value is string { + return ( + typeof value === "string" && publicServiceLogEventIdPattern.test(value) + ); +} + +export class ServiceLogCursorUnavailableError extends Error { + constructor() { + super( + "Log following requires current agents; upgrade agents before continuing", + ); + this.name = "ServiceLogCursorUnavailableError"; + } +} + export function isLoggingEnabled(): boolean { return !!(VICTORIA_LOGS_PRIVATE_URL || VICTORIA_LOGS_URL); } @@ -95,19 +114,23 @@ type QueryLogsByServiceOptions = { serverId?: string; search?: string; range?: LogTimeRange; + signal?: AbortSignal; }; -export async function queryLogsByService( - options: QueryLogsByServiceOptions, -): Promise<{ logs: StoredLog[]; hasMore: boolean }> { - const { serviceId, limit, after, before, logType, serverId, search, range } = - options; +export type PublicServiceLogCursor = { + time: string; + eventId: string; +}; - const endpoint = getQueryEndpoint(); - if (!endpoint) { - throw new Error("VICTORIA_LOGS_URL is not configured"); - } +type PublicServiceLogsOptions = Omit< + QueryLogsByServiceOptions, + "after" | "before" +> & { + cursor?: PublicServiceLogCursor; +}; +function buildServiceLogFilter(options: QueryLogsByServiceOptions): string { + const { serviceId, logType, serverId, search, range } = options; let query = formatLogSqlExactFilter("service_id", serviceId); if (logType === "http") { query += ` log_type:http`; @@ -122,14 +145,6 @@ export async function queryLogsByService( if (range) { query += ` _time:${range}`; } - const afterCursor = normalizeLogCursor(after); - if (afterCursor) { - query += ` _time:>${afterCursor}`; - } - const beforeCursor = normalizeLogCursor(before); - if (beforeCursor) { - query += ` _time:<${beforeCursor}`; - } const searchFilter = formatLogSqlSearchFilter( search, logType === "http" @@ -139,13 +154,131 @@ export async function queryLogsByService( if (searchFilter) { query += ` ${searchFilter}`; } + return query; +} + +function providerSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(5_000); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +async function fetchLogQuery( + endpoint: EndpointConfig, + query: string, + signal?: AbortSignal, +): Promise { + const url = new URL(`${endpoint.url}/select/logsql/query`); + url.searchParams.set("query", query); + url.searchParams.set("timeout", "4s"); + const response = await fetch(url.toString(), { + ...buildFetchOptions(endpoint), + signal: providerSignal(signal), + }); + + if (!response.ok) { + throw new Error( + `Failed to query logs: ${response.status} ${response.statusText}`, + ); + } + + const text = await response.text(); + return text + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as StoredLog); +} + +function deduplicateIdentifiedLogs(logs: StoredLog[]): StoredLog[] { + const seen = new Set(); + return logs.filter((log) => { + if (!log.event_id) return true; + if (seen.has(log.event_id)) return false; + seen.add(log.event_id); + return true; + }); +} + +export async function queryPublicServiceLogs( + options: PublicServiceLogsOptions, +): Promise<{ logs: StoredLog[]; hasMore: boolean }> { + const endpoint = getQueryEndpoint(); + if (!endpoint) { + throw new Error("VICTORIA_LOGS_URL is not configured"); + } + + const pageSize = options.limit + 1; + let query = buildServiceLogFilter({ + ...options, + range: options.range ?? DEFAULT_LOG_TIME_RANGE, + }); + if (options.cursor) { + const cursorTime = normalizeLogCursor(options.cursor.time); + if (!cursorTime) throw new Error("Invalid public service log cursor time"); + if ( + options.cursor.eventId !== "" && + !isPublicServiceLogEventId(options.cursor.eventId) + ) { + throw new Error("Invalid public service log cursor event ID"); + } + if (options.cursor.eventId) { + const eventId = JSON.stringify(options.cursor.eventId); + query += ` (_time:>${cursorTime} OR (_time:>=${cursorTime} _time:<=${cursorTime} event_id:>${eventId}))`; + } else { + query += ` _time:>=${cursorTime}`; + } + query += ` | first ${pageSize} by (_time, event_id)`; + } else { + query += ` | first ${pageSize} by (_time desc, event_id desc) | sort by (_time, event_id)`; + } + + const rawLogs = await fetchLogQuery(endpoint, query, options.signal); + if ( + options.cursor && + rawLogs.some((log) => !isPublicServiceLogEventId(log.event_id)) + ) { + throw new ServiceLogCursorUnavailableError(); + } + const logs = deduplicateIdentifiedLogs(rawLogs); + const hasMore = + options.cursor !== undefined && + (rawLogs.length > options.limit || logs.length > options.limit); + if (logs.length > options.limit) { + if (options.cursor) logs.pop(); + else logs.shift(); + } + return { logs, hasMore }; +} + +export async function queryLogsByService( + options: QueryLogsByServiceOptions, +): Promise<{ logs: StoredLog[]; hasMore: boolean }> { + const { limit, after, before } = options; + + const endpoint = getQueryEndpoint(); + if (!endpoint) { + throw new Error("VICTORIA_LOGS_URL is not configured"); + } + + let query = buildServiceLogFilter(options); + const afterCursor = normalizeLogCursor(after); + if (afterCursor) { + query += ` _time:>${afterCursor}`; + } + const beforeCursor = normalizeLogCursor(before); + if (beforeCursor) { + query += ` _time:<${beforeCursor}`; + } query += " | sort by (_time desc)"; const url = new URL(`${endpoint.url}/select/logsql/query`); url.searchParams.set("query", query); url.searchParams.set("limit", String(limit + 1)); - const response = await fetch(url.toString(), buildFetchOptions(endpoint)); + const response = await fetch(url.toString(), { + ...buildFetchOptions(endpoint), + signal: providerSignal(options.signal), + }); if (!response.ok) { throw new Error( diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts index d437a8d5..d1348567 100644 --- a/web/lib/victoria-metrics.ts +++ b/web/lib/victoria-metrics.ts @@ -261,6 +261,7 @@ export async function queryServiceMetrics(options: { serviceId: string; range: MetricRange; now?: Date; + throwOnError?: boolean; }): Promise { const endpoint = getQueryEndpoint(); const now = options.now ?? new Date(); @@ -276,6 +277,8 @@ export async function queryServiceMetrics(options: { window.start, window.stepSeconds * SECOND_IN_MILLISECONDS, ); + const recover = (promise: Promise, fallback: T): Promise => + options.throwOnError ? promise : promise.catch(() => fallback); const [ requestResults, @@ -291,76 +294,112 @@ export async function queryServiceMetrics(options: { totalIngressBytes, totalEgressBytes, ] = await Promise.all([ - queryRangePromQL(endpoint, { - query: `sum by (code) (increase(traefik_service_requests_total{${traefikFilter}}[${rangeWindow}]))`, - start: queryStart, - end: window.end, - stepSeconds: window.stepSeconds, - }).catch(() => []), - queryRangePromQL(endpoint, { - query: responseTimeQuery(0.5, traefikFilter, rangeWindow), - start: queryStart, - end: window.end, - stepSeconds: window.stepSeconds, - }).catch(() => []), - queryRangePromQL(endpoint, { - query: responseTimeQuery(0.9, traefikFilter, rangeWindow), - start: queryStart, - end: window.end, - stepSeconds: window.stepSeconds, - }).catch(() => []), - queryRangePromQL(endpoint, { - query: responseTimeQuery(0.95, traefikFilter, rangeWindow), - start: queryStart, - end: window.end, - stepSeconds: window.stepSeconds, - }).catch(() => []), - queryRangePromQL(endpoint, { - query: responseTimeQuery(0.99, traefikFilter, rangeWindow), - start: queryStart, - end: window.end, - stepSeconds: window.stepSeconds, - }).catch(() => []), - queryRangePromQL(endpoint, { - query: `sum(rate(traefik_service_requests_bytes_total{${traefikFilter}}[${rangeWindow}]))`, - start: queryStart, - end: window.end, - stepSeconds: window.stepSeconds, - }).catch(() => []), - queryRangePromQL(endpoint, { - query: `sum(rate(traefik_service_responses_bytes_total{${traefikFilter}}[${rangeWindow}]))`, - start: queryStart, - end: window.end, - stepSeconds: window.stepSeconds, - }).catch(() => []), - queryRangePromQL(endpoint, { - query: `sum(avg_over_time(techulus_service_cpu_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`, - start: queryStart, - end: window.end, - stepSeconds: window.stepSeconds, - }).catch(() => []), - queryRangePromQL(endpoint, { - query: `sum(avg_over_time(techulus_service_memory_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`, - start: queryStart, - end: window.end, - stepSeconds: window.stepSeconds, - }).catch(() => []), - queryRangePromQL(endpoint, { - query: `sum(avg_over_time(techulus_service_memory_used_bytes{service_id="${serviceId}"}[${rangeWindow}]))`, - start: queryStart, - end: window.end, - stepSeconds: window.stepSeconds, - }).catch(() => []), - queryInstantPromQL( - endpoint, - `sum(increase(traefik_service_requests_bytes_total{${traefikFilter}}[${totalWindow}]))`, - window.end, - ).catch(() => null), - queryInstantPromQL( - endpoint, - `sum(increase(traefik_service_responses_bytes_total{${traefikFilter}}[${totalWindow}]))`, - window.end, - ).catch(() => null), + recover( + queryRangePromQL(endpoint, { + query: `sum by (code) (increase(traefik_service_requests_total{${traefikFilter}}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }), + [], + ), + recover( + queryRangePromQL(endpoint, { + query: responseTimeQuery(0.5, traefikFilter, rangeWindow), + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }), + [], + ), + recover( + queryRangePromQL(endpoint, { + query: responseTimeQuery(0.9, traefikFilter, rangeWindow), + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }), + [], + ), + recover( + queryRangePromQL(endpoint, { + query: responseTimeQuery(0.95, traefikFilter, rangeWindow), + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }), + [], + ), + recover( + queryRangePromQL(endpoint, { + query: responseTimeQuery(0.99, traefikFilter, rangeWindow), + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }), + [], + ), + recover( + queryRangePromQL(endpoint, { + query: `sum(rate(traefik_service_requests_bytes_total{${traefikFilter}}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }), + [], + ), + recover( + queryRangePromQL(endpoint, { + query: `sum(rate(traefik_service_responses_bytes_total{${traefikFilter}}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }), + [], + ), + recover( + queryRangePromQL(endpoint, { + query: `sum(avg_over_time(techulus_service_cpu_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }), + [], + ), + recover( + queryRangePromQL(endpoint, { + query: `sum(avg_over_time(techulus_service_memory_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }), + [], + ), + recover( + queryRangePromQL(endpoint, { + query: `sum(avg_over_time(techulus_service_memory_used_bytes{service_id="${serviceId}"}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }), + [], + ), + recover( + queryInstantPromQL( + endpoint, + `sum(increase(traefik_service_requests_bytes_total{${traefikFilter}}[${totalWindow}]))`, + window.end, + ), + null, + ), + recover( + queryInstantPromQL( + endpoint, + `sum(increase(traefik_service_responses_bytes_total{${traefikFilter}}[${totalWindow}]))`, + window.end, + ), + null, + ), ]); const buckets = createServiceMetricBuckets(window); diff --git a/web/lib/work-queue.ts b/web/lib/work-queue.ts index 82af9533..8379998c 100644 --- a/web/lib/work-queue.ts +++ b/web/lib/work-queue.ts @@ -43,13 +43,17 @@ export async function enqueueWork( serverId: string, type: WorkQueue["type"], payload: Record, + options: { id?: string } = {}, ) { - await db.insert(workQueue).values({ - id: randomUUID(), - serverId, - type, - payload: JSON.stringify(payload), - }); + await db + .insert(workQueue) + .values({ + id: options.id ?? randomUUID(), + serverId, + type, + payload: JSON.stringify(payload), + }) + .onConflictDoNothing({ target: workQueue.id }); } export async function completeWorkItemResults( @@ -247,27 +251,49 @@ async function runWorkItemCompletionSideEffects( try { const payload = JSON.parse(item.payload) as { serviceId?: string; + serviceRevisionId?: string; finalImageUri?: string; buildGroupId?: string; }; if (result.status === "completed") { - if (payload.serviceId && payload.finalImageUri) { + if ( + payload.serviceId && + payload.serviceRevisionId && + payload.buildGroupId && + payload.finalImageUri + ) { await inngest.send( - inngestEvents.manifestCompleted.create({ - serviceId: payload.serviceId, - buildGroupId: payload.buildGroupId || "", - imageUri: payload.finalImageUri, - }), + inngestEvents.manifestCompleted.create( + { + serviceId: payload.serviceId, + serviceRevisionId: payload.serviceRevisionId, + buildGroupId: payload.buildGroupId, + imageUri: payload.finalImageUri, + }, + { + id: `manifest-completed-${payload.buildGroupId}`, + }, + ), ); } - } else if (payload.serviceId) { + } else if ( + payload.serviceId && + payload.serviceRevisionId && + payload.buildGroupId + ) { await inngest.send( - inngestEvents.manifestFailed.create({ - serviceId: payload.serviceId, - buildGroupId: payload.buildGroupId || "", - error: result.error || "Manifest creation failed", - }), + inngestEvents.manifestFailed.create( + { + serviceId: payload.serviceId, + serviceRevisionId: payload.serviceRevisionId, + buildGroupId: payload.buildGroupId, + error: result.error || "Manifest creation failed", + }, + { + id: `manifest-failed-${payload.buildGroupId}`, + }, + ), ); } } catch (error) { diff --git a/web/tests/api-key-session-isolation.test.ts b/web/tests/api-key-session-isolation.test.ts new file mode 100644 index 00000000..bdd5102a --- /dev/null +++ b/web/tests/api-key-session-isolation.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { auth } from "@/lib/auth"; + +describe("API key session isolation", () => { + it("does not turn X-API-Key into a dashboard session", async () => { + const session = await auth.api.getSession({ + headers: new Headers({ + "x-api-key": `tcl_${"x".repeat(64)}`, + }), + }); + + expect(session).toBeNull(); + }); +}); diff --git a/web/tests/build-actions.test.ts b/web/tests/build-actions.test.ts index 9f2b7d5c..04b86174 100644 --- a/web/tests/build-actions.test.ts +++ b/web/tests/build-actions.test.ts @@ -2,7 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const queryResult = { - service: { id: "service-1", sourceType: "github" }, + service: { + id: "service-1", + sourceType: "github", + githubRootDir: "apps/web", + }, githubRepo: { id: "repo-link-1", installationId: 123, @@ -29,6 +33,7 @@ const mocks = vi.hoisted(() => { listGitHubCommits: vi.fn(), send: vi.fn(), createBuildTrigger: vi.fn((data) => ({ name: "build/trigger", data })), + triggerResolvedBuildInternal: vi.fn(), }; }); @@ -48,6 +53,11 @@ vi.mock("@/lib/inngest/events", () => ({ buildTrigger: { create: mocks.createBuildTrigger }, }, })); +vi.mock("@/lib/trigger-build", () => ({ + triggerBuildInternal: vi.fn(), + requeueBuildRevisionInternal: vi.fn(), + triggerResolvedBuildInternal: mocks.triggerResolvedBuildInternal, +})); import { triggerManualBuild } from "@/actions/builds"; @@ -62,9 +72,14 @@ describe("manual commit builds", () => { beforeEach(() => { mocks.db.select.mockClear(); mocks.requireDeveloperRole.mockReset(); + mocks.requireDeveloperRole.mockResolvedValue({ + user: { id: "user-1", name: "Alice" }, + }); mocks.listGitHubCommits.mockReset(); mocks.send.mockReset(); mocks.createBuildTrigger.mockClear(); + mocks.triggerResolvedBuildInternal.mockReset(); + mocks.triggerResolvedBuildInternal.mockResolvedValue({ status: "queued" }); }); it("queues a commit from the latest 50 on the deploy branch", async () => { @@ -79,16 +94,18 @@ describe("manual commit builds", () => { "techulus/cloud", "production", ); - expect(mocks.createBuildTrigger).toHaveBeenCalledWith({ - serviceId: "service-1", - trigger: "manual", - githubRepoId: "repo-link-1", - commitSha: selectedCommit.sha, - commitMessage: selectedCommit.message, - branch: "production", - author: "octocat", - }); - expect(mocks.send).toHaveBeenCalledTimes(1); + expect(mocks.triggerResolvedBuildInternal).toHaveBeenCalledWith( + "service-1", + { + trigger: "manual", + commitSha: selectedCommit.sha, + commitMessage: selectedCommit.message, + author: "octocat", + expectedRepository: "https://github.com/techulus/cloud", + expectedBranch: "production", + actor: { type: "user", userId: "user-1", name: "Alice" }, + }, + ); }); it("rejects a commit outside the latest 50 without queueing a build", async () => { @@ -99,7 +116,7 @@ describe("manual commit builds", () => { ).rejects.toThrow( "Selected commit is no longer among the latest 50 commits on the source branch", ); - expect(mocks.send).not.toHaveBeenCalled(); + expect(mocks.triggerResolvedBuildInternal).not.toHaveBeenCalled(); }); it("rejects an unsafe SHA before querying the service", async () => { @@ -108,6 +125,6 @@ describe("manual commit builds", () => { ).rejects.toThrow("Commit SHA must be a full 40-character hexadecimal SHA"); expect(mocks.db.select).not.toHaveBeenCalled(); expect(mocks.listGitHubCommits).not.toHaveBeenCalled(); - expect(mocks.send).not.toHaveBeenCalled(); + expect(mocks.triggerResolvedBuildInternal).not.toHaveBeenCalled(); }); }); diff --git a/web/tests/build-assignment.test.ts b/web/tests/build-assignment.test.ts index da69c7c8..7a18320f 100644 --- a/web/tests/build-assignment.test.ts +++ b/web/tests/build-assignment.test.ts @@ -1,30 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ServiceRevisionSpec } from "@/lib/service-revision-spec"; const mocks = vi.hoisted(() => { - // Results are consumed in the same order as db.select() calls in each test. const queryResults: unknown[][] = []; - const queries: Array<{ - from: ReturnType; - innerJoin: ReturnType; - where: ReturnType; - }> = []; - + const queries: Array<{ where: ReturnType }> = []; function createQuery(result: unknown[]) { const query = { from: vi.fn(() => query), - innerJoin: vi.fn(() => query), where: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. then: ( resolve: (value: unknown[]) => unknown, reject?: (reason: unknown) => unknown, - ) => - Promise.resolve(result).then(resolve, reject), + ) => Promise.resolve(result).then(resolve, reject), }; - queries.push(query); return query; } - return { queryResults, queries, @@ -39,11 +31,51 @@ vi.mock("@/db", () => ({ db: mocks.db })); vi.mock("@/db/queries", () => ({ getSetting: mocks.getSetting })); import { - getTargetPlatformsForService, - selectBuildServerForPlatform, + getTargetPlatformsForRevision, + selectBuildServerForRevision, } from "@/lib/build-assignment"; -describe("build assignment", () => { +function specification( + overrides: Partial = {}, +): ServiceRevisionSpec { + return { + schemaVersion: 2, + image: "registry/app:revision-1", + source: { type: "image", image: "registry/app:revision-1" }, + hostname: "app", + stateful: false, + serverless: { + enabled: false, + sleepAfterSeconds: 300, + wakeTimeoutSeconds: 300, + }, + healthCheck: null, + startCommand: null, + resourceLimits: { cpuCores: null, memoryMb: null }, + placements: [{ serverId: "server-target", count: 1 }], + ports: [], + secrets: [], + volumes: [], + ...overrides, + }; +} + +function sqlTokens(value: unknown): unknown[] { + if (!value || typeof value !== "object") return [value]; + const record = value as { + name?: string; + queryChunks?: unknown[]; + value?: unknown; + }; + if (Array.isArray(record.queryChunks)) { + return record.queryChunks.flatMap(sqlTokens); + } + if (Array.isArray(record.value)) return record.value.flatMap(sqlTokens); + if ("value" in record) return [record.value]; + return record.name ? [record.name] : []; +} + +describe("revision-backed build assignment", () => { beforeEach(() => { mocks.queryResults.length = 0; mocks.queries.length = 0; @@ -51,183 +83,111 @@ describe("build assignment", () => { mocks.getSetting.mockReset(); }); - function sqlTokens(value: unknown): unknown[] { - if (!value || typeof value !== "object") { - return [value]; - } - - const record = value as { - name?: string; - queryChunks?: unknown[]; - value?: unknown; - }; - - if (Array.isArray(record.queryChunks)) { - return record.queryChunks.flatMap(sqlTokens); - } - - if (Array.isArray(record.value)) { - return record.value.flatMap(sqlTokens); - } - - if ("value" in record) { - return [record.value]; - } - - if (record.name) { - return [record.name]; - } - - return []; - } - - function expectActiveReplicaPredicate(queryIndex: number) { - const condition = mocks.queries[queryIndex]?.where.mock.calls[0]?.[0]; - const tokens = sqlTokens(condition); - - expect(tokens).toEqual(expect.arrayContaining(["service_id", "count", 0])); - expect(tokens).toContain(" > "); - } - - it("assigns stateful builds to the active replica server", async () => { - mocks.queryResults.push( - [{ id: "service_1", stateful: true }], - [ - { - id: "server_target", - status: "online", - meta: { arch: "arm64" }, - }, - ], - ); - - await expect( - selectBuildServerForPlatform("service_1", "linux/arm64"), - ).resolves.toBe("server_target"); - expectActiveReplicaPredicate(1); - }); - - it("rejects stateful builds without exactly one active replica server", async () => { - mocks.queryResults.push([{ id: "service_1", stateful: true }], []); + it("assigns a stateful build to its snapshotted placement server", async () => { + mocks.queryResults.push([ + { id: "server-target", status: "online", meta: { arch: "arm64" } }, + ]); await expect( - selectBuildServerForPlatform("service_1", "linux/arm64"), - ).rejects.toThrow( - "Stateful services must have exactly one active replica server", - ); + selectBuildServerForRevision( + specification({ stateful: true }), + "linux/arm64", + ), + ).resolves.toBe("server-target"); }); - it("rejects stateful builds with multiple active replica rows", async () => { - mocks.queryResults.push( - [{ id: "service_1", stateful: true }], - [ - { id: "server_1", status: "online", meta: { arch: "arm64" } }, - { id: "server_2", status: "online", meta: { arch: "arm64" } }, - ], - ); - + it("rejects an invalid stateful placement without reading mutable service rows", async () => { await expect( - selectBuildServerForPlatform("service_1", "linux/arm64"), + selectBuildServerForRevision( + specification({ stateful: true, placements: [] }), + "linux/arm64", + ), ).rejects.toThrow( - "Stateful services must have exactly one active replica server", + "Stateful service revisions must have exactly one active replica server", ); + expect(mocks.db.select).not.toHaveBeenCalled(); }); - it("rejects stateful builds when the active replica server is offline", async () => { - mocks.queryResults.push( - [{ id: "service_1", stateful: true }], - [{ id: "server_target", status: "offline", meta: { arch: "arm64" } }], - ); - + it("rejects an offline stateful revision target", async () => { + mocks.queryResults.push([ + { id: "server-target", status: "offline", meta: { arch: "arm64" } }, + ]); await expect( - selectBuildServerForPlatform("service_1", "linux/arm64"), + selectBuildServerForRevision( + specification({ stateful: true }), + "linux/arm64", + ), ).rejects.toThrow("Stateful service target server is offline"); }); - it("rejects stateful builds when the active replica arch does not match the requested platform", async () => { - mocks.queryResults.push( - [{ id: "service_1", stateful: true }], - [ - { - id: "server_target", - status: "online", - meta: { arch: "arm64" }, - }, - ], - ); - + it("rejects a stateful platform that differs from the revision target", async () => { + mocks.queryResults.push([ + { id: "server-target", status: "online", meta: { arch: "arm64" } }, + ]); await expect( - selectBuildServerForPlatform("service_1", "linux/amd64"), + selectBuildServerForRevision( + specification({ stateful: true }), + "linux/amd64", + ), ).rejects.toThrow( "Stateful service target server architecture arm64 does not match platform linux/amd64", ); - expectActiveReplicaPredicate(1); }); - it("builds a stateful target platform from the active replica server architecture", async () => { - mocks.queryResults.push( - [{ id: "service_1", stateful: true }], - [{ id: "server_target", status: "online", meta: { arch: "arm64" } }], - ); - - await expect(getTargetPlatformsForService("service_1")).resolves.toEqual([ - "linux/arm64", + it("derives a stateful target platform from the revision placement", async () => { + mocks.queryResults.push([ + { id: "server-target", status: "online", meta: { arch: "arm64" } }, ]); - expectActiveReplicaPredicate(1); - }); - - it("rejects stateful target platform resolution when the active replica arch is unknown", async () => { - mocks.queryResults.push( - [{ id: "service_1", stateful: true }], - [{ id: "server_target", status: "online", meta: null }], - ); - - await expect(getTargetPlatformsForService("service_1")).rejects.toThrow( - "Stateful service target server architecture is unknown", - ); + await expect( + getTargetPlatformsForRevision(specification({ stateful: true })), + ).resolves.toEqual(["linux/arm64"]); }); - it("keeps stateless builds assigned by matching architecture", async () => { + it("assigns stateless builds to an online server with the requested architecture", async () => { mocks.getSetting.mockResolvedValue(null); - mocks.queryResults.push( - [{ id: "service_1", stateful: false }], - [ - { id: "server_amd", meta: { arch: "amd64" } }, - { id: "server_arm", meta: { arch: "arm64" } }, - ], - ); - + mocks.queryResults.push([ + { id: "server-amd", meta: { arch: "amd64" } }, + { id: "server-arm", meta: { arch: "arm64" } }, + ]); await expect( - selectBuildServerForPlatform("service_1", "linux/arm64"), - ).resolves.toBe("server_arm"); + selectBuildServerForRevision(specification(), "linux/arm64"), + ).resolves.toBe("server-arm"); }); - it("limits stateless build assignment to allowed build servers", async () => { - mocks.getSetting.mockResolvedValue(["server_arm"]); - mocks.queryResults.push( - [{ id: "service_1", stateful: false }], - [{ id: "server_arm", meta: { arch: "arm64" } }], - ); - + it("limits stateless build assignment to configured build servers", async () => { + mocks.getSetting.mockResolvedValue(["server-arm"]); + mocks.queryResults.push([{ id: "server-arm", meta: { arch: "arm64" } }]); await expect( - selectBuildServerForPlatform("service_1", "linux/arm64"), - ).resolves.toBe("server_arm"); + selectBuildServerForRevision(specification(), "linux/arm64"), + ).resolves.toBe("server-arm"); - const condition = mocks.queries[1]?.where.mock.calls[0]?.[0]; + const condition = mocks.queries[0]?.where.mock.calls[0]?.[0]; expect(sqlTokens(condition)).toEqual( expect.arrayContaining(["status", "online", "id", " in "]), ); }); - it("builds target platforms from active replica server architectures", async () => { - mocks.queryResults.push( - [{ id: "service_1" }], - [{ meta: { arch: "arm64" } }, { meta: { arch: "arm64" } }], - ); - - await expect(getTargetPlatformsForService("service_1")).resolves.toEqual([ - "linux/arm64", + it("derives stateless target platforms from snapshotted placements", async () => { + mocks.queryResults.push([ + { id: "server-a", meta: { arch: "arm64" } }, + { id: "server-b", meta: { arch: "arm64" } }, ]); - expectActiveReplicaPredicate(1); + await expect( + getTargetPlatformsForRevision( + specification({ + placements: [ + { serverId: "server-a", count: 1 }, + { serverId: "server-b", count: 1 }, + ], + }), + ), + ).resolves.toEqual(["linux/arm64"]); + }); + + it("uses both default platforms for an unplaced build revision", async () => { + await expect( + getTargetPlatformsForRevision(specification({ placements: [] })), + ).resolves.toEqual(["linux/amd64", "linux/arm64"]); + expect(mocks.db.select).not.toHaveBeenCalled(); }); }); diff --git a/web/tests/build-claim-route.test.ts b/web/tests/build-claim-route.test.ts new file mode 100644 index 00000000..c076f272 --- /dev/null +++ b/web/tests/build-claim-route.test.ts @@ -0,0 +1,109 @@ +import type { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const selectResults: unknown[][] = []; + const updateResults: unknown[][] = []; + const updateSets: Array> = []; + function selectQuery(result: unknown[]) { + const query = { + from: vi.fn(() => query), + where: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return query; + } + function updateQuery(result: unknown[]) { + const query = { + set: vi.fn((value: Record) => { + updateSets.push(value); + return query; + }), + where: vi.fn(() => query), + returning: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return query; + } + return { + selectResults, + updateResults, + updateSets, + db: { + select: vi.fn(() => selectQuery(selectResults.shift() ?? [])), + update: vi.fn(() => updateQuery(updateResults.shift() ?? [])), + }, + verifyAgentRequest: vi.fn(), + getSetting: vi.fn(), + send: vi.fn(), + }; +}); + +vi.mock("@/db", () => ({ db: mocks.db })); +vi.mock("@/db/queries", () => ({ getSetting: mocks.getSetting })); +vi.mock("@/lib/agent-auth", () => ({ + verifyAgentRequest: mocks.verifyAgentRequest, +})); +vi.mock("@/lib/inngest/client", () => ({ inngest: { send: mocks.send } })); + +import { POST } from "@/app/api/v1/agent/builds/[id]/route"; + +const build = { + id: "build-amd64", + serviceId: "service-1", + serviceRevisionId: "revision-1", + buildGroupId: "group-1", + targetPlatform: "linux/amd64", + commitSha: "0123456789abcdef0123456789abcdef01234567", + commitMessage: "Build revision", + branch: "main", +}; + +describe("agent build claim", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.selectResults.length = 0; + mocks.updateResults.length = 0; + mocks.updateSets.length = 0; + mocks.verifyAgentRequest.mockResolvedValue({ + success: true, + serverId: "server-1", + }); + mocks.getSetting.mockResolvedValue(undefined); + }); + + it("does not overwrite cancellation when claim validation fails", async () => { + mocks.updateResults.push([build], []); + mocks.selectResults.push( + [{ id: "service-1", projectId: "project-1" }], + [], + ); + + const response = await POST( + new Request("http://localhost/api/v1/agent/builds/build-amd64", { + method: "POST", + }) as NextRequest, + { params: Promise.resolve({ id: "build-amd64" }) }, + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "Build was cancelled while being claimed", + }); + expect(mocks.updateSets).toHaveLength(2); + expect(mocks.updateSets[1]).toMatchObject({ + status: "failed", + error: "Build service revision not found", + completedAt: expect.any(Date), + }); + expect(mocks.send).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/build-revision-source.test.ts b/web/tests/build-revision-source.test.ts new file mode 100644 index 00000000..ce5ca99f --- /dev/null +++ b/web/tests/build-revision-source.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { cloneUrlForRevisionSource } from "@/lib/build-revision-source"; + +const baseSource = { + type: "github" as const, + repository: "https://github.com/techulus/cloud", + repositoryId: 123, + branch: "main", + commitSha: "0123456789abcdef0123456789abcdef01234567", + rootDir: "web", +}; + +describe("revision-backed GitHub clone authentication", () => { + it("uses the snapshotted GitHub App installation", async () => { + const getToken = vi.fn().mockResolvedValue("installation-token"); + + await expect( + cloneUrlForRevisionSource( + { + ...baseSource, + authentication: { type: "github_app", installationId: 456 }, + }, + getToken, + ), + ).resolves.toBe( + "https://x-access-token:installation-token@github.com/techulus/cloud.git", + ); + expect(getToken).toHaveBeenCalledWith(456); + }); + + it("never falls back to an anonymous clone when App authentication fails", async () => { + const getToken = vi.fn().mockRejectedValue(new Error("installation removed")); + + await expect( + cloneUrlForRevisionSource( + { + ...baseSource, + authentication: { type: "github_app", installationId: 456 }, + }, + getToken, + ), + ).rejects.toThrow("installation removed"); + expect(getToken).toHaveBeenCalledTimes(1); + }); + + it("uses the snapshotted public repository only for anonymous sources", async () => { + const getToken = vi.fn(); + + await expect( + cloneUrlForRevisionSource( + { + ...baseSource, + repositoryId: null, + authentication: { type: "anonymous" }, + }, + getToken, + ), + ).resolves.toBe("https://github.com/techulus/cloud.git"); + expect(getToken).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/build-status-route.test.ts b/web/tests/build-status-route.test.ts new file mode 100644 index 00000000..de79684e --- /dev/null +++ b/web/tests/build-status-route.test.ts @@ -0,0 +1,222 @@ +import type { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const selectResults: unknown[][] = []; + const updateResults: unknown[][] = []; + const updateSets: Array> = []; + function selectQuery(result: unknown[]) { + const query = { + from: vi.fn(() => query), + where: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return query; + } + function updateQuery(result: unknown[]) { + const query = { + set: vi.fn((value: Record) => { + updateSets.push(value); + return query; + }), + where: vi.fn(() => query), + returning: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return query; + } + return { + selectResults, + updateResults, + updateSets, + db: { + select: vi.fn(() => selectQuery(selectResults.shift() ?? [])), + update: vi.fn(() => updateQuery(updateResults.shift() ?? [])), + }, + verifyAgentRequest: vi.fn(), + enqueueWork: vi.fn(), + send: vi.fn(), + createBuildCompleted: vi.fn((data, options) => ({ + name: "build/completed", + data, + ...options, + })), + }; +}); + +vi.mock("@/db", () => ({ db: mocks.db })); +vi.mock("@/lib/agent-auth", () => ({ + verifyAgentRequest: mocks.verifyAgentRequest, +})); +vi.mock("@/lib/email", () => ({ sendBuildFailureAlert: vi.fn() })); +vi.mock("@/lib/github", () => ({ updateGitHubDeploymentStatus: vi.fn() })); +vi.mock("@/lib/work-queue", () => ({ enqueueWork: mocks.enqueueWork })); +vi.mock("@/lib/inngest/client", () => ({ inngest: { send: mocks.send } })); +vi.mock("@/lib/inngest/events", () => ({ + inngestEvents: { + buildCompleted: { create: mocks.createBuildCompleted }, + }, +})); + +import { POST } from "@/app/api/v1/agent/builds/[id]/status/route"; + +const commitSha = "0123456789abcdef0123456789abcdef01234567"; +const finalImage = "registry.test/project-1/service-1:revision-revision-1"; + +const specification = { + schemaVersion: 2, + image: finalImage, + source: { + type: "github", + repository: "https://github.com/acme/app", + repositoryId: null, + branch: "main", + commitSha, + rootDir: "apps/web", + authentication: { type: "anonymous" }, + }, + hostname: "service-1", + stateful: false, + serverless: { + enabled: false, + sleepAfterSeconds: 300, + wakeTimeoutSeconds: 300, + }, + healthCheck: null, + startCommand: null, + resourceLimits: { cpuCores: null, memoryMb: null }, + placements: [{ serverId: "server-1", count: 1 }], + ports: [], + secrets: [], + volumes: [], +}; + +function build(status: string, overrides: Record = {}) { + return { + id: "build-amd64", + serviceId: "service-1", + serviceRevisionId: "revision-1", + buildGroupId: "group-1", + targetPlatform: "linux/amd64", + commitSha, + branch: "main", + status, + claimedBy: "server-1", + startedAt: new Date(), + githubDeploymentId: null, + imageUri: null, + ...overrides, + }; +} + +function post(status: string) { + return POST( + new Request("http://localhost/api/v1/agent/builds/build-amd64/status", { + method: "POST", + body: JSON.stringify({ status, resolvedCommitSha: commitSha }), + }) as NextRequest, + { params: Promise.resolve({ id: "build-amd64" }) }, + ); +} + +describe("agent build status transitions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.selectResults.length = 0; + mocks.updateResults.length = 0; + mocks.updateSets.length = 0; + mocks.verifyAgentRequest.mockResolvedValue({ + success: true, + serverId: "server-1", + }); + mocks.enqueueWork.mockResolvedValue(undefined); + mocks.send.mockResolvedValue(undefined); + }); + + it("stores completion and the platform artifact atomically", async () => { + const completedBuild = build("completed", { + imageUri: `${finalImage}-amd64`, + }); + mocks.selectResults.push( + [build("pushing")], + [{ specification }], + [ + completedBuild, + build("completed", { + id: "build-arm64", + targetPlatform: "linux/arm64", + imageUri: `${finalImage}-arm64`, + }), + ], + ); + mocks.updateResults.push([completedBuild]); + + const response = await post("completed"); + + expect(response.status).toBe(200); + expect(mocks.updateSets).toHaveLength(1); + expect(mocks.updateSets[0]).toMatchObject({ + status: "completed", + imageUri: `${finalImage}-amd64`, + completedAt: expect.any(Date), + }); + expect(mocks.enqueueWork).toHaveBeenCalledWith( + "server-1", + "create_manifest", + { + images: [`${finalImage}-amd64`, `${finalImage}-arm64`], + finalImageUri: finalImage, + serviceId: "service-1", + serviceRevisionId: "revision-1", + buildGroupId: "group-1", + }, + { id: "manifest-work-group-1" }, + ); + expect(mocks.createBuildCompleted).toHaveBeenCalledWith( + expect.objectContaining({ status: "success" }), + { id: "build-completed-build-amd64" }, + ); + }); + + it("does not overwrite a concurrent cancellation", async () => { + mocks.selectResults.push( + [build("pushing")], + [{ specification }], + [build("cancelled")], + ); + mocks.updateResults.push([]); + + const response = await post("completed"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, cancelled: true }); + expect(mocks.enqueueWork).not.toHaveBeenCalled(); + expect(mocks.send).not.toHaveBeenCalled(); + }); + + it("rejects reversal of a completed build to failed", async () => { + const completedBuild = build("completed", { + imageUri: `${finalImage}-amd64`, + }); + mocks.selectResults.push( + [completedBuild], + [{ specification }], + [completedBuild], + ); + mocks.updateResults.push([]); + + const response = await post("failed"); + + expect(response.status).toBe(409); + expect(mocks.enqueueWork).not.toHaveBeenCalled(); + expect(mocks.send).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/build-trigger-workflow.test.ts b/web/tests/build-trigger-workflow.test.ts new file mode 100644 index 00000000..c7f25687 --- /dev/null +++ b/web/tests/build-trigger-workflow.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + values: vi.fn(), + onConflictDoNothing: vi.fn(), + returning: vi.fn(), + revisionRows: [] as unknown[], + getTargetPlatformsForRevision: vi.fn(), + selectBuildServerForRevision: vi.fn(), + enqueueWork: vi.fn(), + send: vi.fn(), + createBuildStarted: vi.fn((data) => ({ name: "build/started", data })), +})); + +vi.mock("@/db", () => ({ + db: { + insert: vi.fn(() => ({ values: mocks.values })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve(mocks.revisionRows)), + })), + })), + }, +})); +vi.mock("@/db/schema", () => ({ + builds: { id: "id" }, + serviceRevisions: { + id: "id", + serviceId: "service_id", + specification: "specification", + }, +})); +vi.mock("@/lib/build-assignment", () => ({ + getTargetPlatformsForRevision: mocks.getTargetPlatformsForRevision, + selectBuildServerForRevision: mocks.selectBuildServerForRevision, +})); +vi.mock("@/lib/work-queue", () => ({ enqueueWork: mocks.enqueueWork })); +vi.mock("@/lib/inngest/client", () => ({ + inngest: { + createFunction: vi.fn( + (_options: unknown, handler: (input: unknown) => unknown) => handler, + ), + send: mocks.send, + }, +})); +vi.mock("@/lib/inngest/events", () => ({ + inngestEvents: { + buildTrigger: { name: "build/trigger" }, + buildStarted: { create: mocks.createBuildStarted }, + }, +})); + +import { buildTriggerWorkflow } from "@/lib/inngest/functions/build-trigger-workflow"; + +const exactSha = "0123456789ABCDEF0123456789ABCDEF01234567"; + +function invoke(commitSha: string) { + const step = { + run: vi.fn(async (_name: string, operation: () => Promise) => + operation(), + ), + }; + const handler = buildTriggerWorkflow as unknown as (input: { + event: { data: Record }; + step: typeof step; + }) => Promise; + return handler({ + event: { + data: { + serviceId: "service-1", + serviceRevisionId: "revision-1", + buildRequestId: "request-1", + trigger: "manual", + commitSha, + commitMessage: "Exact source commit", + branch: "main", + author: "octocat", + actor: { type: "system" }, + }, + }, + step, + }); +} + +describe("build trigger fan-out", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.revisionRows.length = 0; + mocks.revisionRows.push({ + specification: { + schemaVersion: 2, + image: "registry.example.com/service-1:revision-1", + source: { + type: "github", + repository: "https://github.com/owner/repository", + repositoryId: null, + branch: "main", + commitSha: exactSha.toLowerCase(), + rootDir: null, + authentication: { type: "anonymous" }, + }, + hostname: "service-1", + stateful: false, + serverless: { + enabled: false, + sleepAfterSeconds: 300, + wakeTimeoutSeconds: 300, + }, + healthCheck: null, + startCommand: null, + resourceLimits: { cpuCores: null, memoryMb: null }, + placements: [], + ports: [], + secrets: [], + volumes: [], + }, + }); + mocks.values.mockReturnValue({ + onConflictDoNothing: mocks.onConflictDoNothing, + }); + mocks.onConflictDoNothing.mockReturnValue({ returning: mocks.returning }); + mocks.returning.mockResolvedValue([{ id: "build-1" }, { id: "build-2" }]); + mocks.getTargetPlatformsForRevision.mockResolvedValue([ + "linux/amd64", + "linux/arm64", + ]); + mocks.selectBuildServerForRevision.mockResolvedValue("server-1"); + }); + + it("persists one immutable commit for every target platform", async () => { + await invoke(exactSha); + + expect(mocks.values).toHaveBeenCalledTimes(1); + const rows = mocks.values.mock.calls[0]?.[0]; + expect( + rows.map((row: Record) => ({ + commitSha: row.commitSha, + serviceRevisionId: row.serviceRevisionId, + buildGroupId: row.buildGroupId, + targetPlatform: row.targetPlatform, + })), + ).toEqual([ + { + commitSha: exactSha.toLowerCase(), + serviceRevisionId: "revision-1", + buildGroupId: "request-1", + targetPlatform: "linux/amd64", + }, + { + commitSha: exactSha.toLowerCase(), + serviceRevisionId: "revision-1", + buildGroupId: "request-1", + targetPlatform: "linux/arm64", + }, + ]); + expect( + mocks.selectBuildServerForRevision.mock.invocationCallOrder[1], + ).toBeLessThan(mocks.values.mock.invocationCallOrder[0]); + expect(mocks.enqueueWork).toHaveBeenCalledTimes(2); + }); + + it("rejects a moving ref before creating any platform build", async () => { + await expect(invoke("HEAD")).rejects.toThrow( + "Build fan-out requires a full 40-character commit SHA", + ); + + expect(mocks.getTargetPlatformsForRevision).not.toHaveBeenCalled(); + expect(mocks.values).not.toHaveBeenCalled(); + expect(mocks.enqueueWork).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/build-workflow.test.ts b/web/tests/build-workflow.test.ts new file mode 100644 index 00000000..d129e7fd --- /dev/null +++ b/web/tests/build-workflow.test.ts @@ -0,0 +1,290 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const queryResults: unknown[][] = []; + function query(result: unknown[]) { + const builder = { + from: vi.fn(() => builder), + where: vi.fn(() => builder), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return builder; + } + return { + queryResults, + select: vi.fn(() => query(queryResults.shift() ?? [])), + deployServiceRevisionInternal: vi.fn(), + }; +}); + +vi.mock("@/db", () => ({ db: { select: mocks.select } })); +vi.mock("@/lib/deploy-service", () => ({ + deployServiceRevisionInternal: mocks.deployServiceRevisionInternal, +})); +vi.mock("@/lib/inngest/client", () => ({ + inngest: { + createFunction: vi.fn( + (_options: unknown, handler: (input: unknown) => unknown) => handler, + ), + }, +})); +vi.mock("@/lib/inngest/events", () => ({ + inngestEvents: { + buildStarted: { name: "build/started" }, + buildCancelled: { name: "build/cancelled" }, + buildCompleted: { name: "build/completed" }, + manifestCompleted: { name: "manifest/completed" }, + }, +})); + +import { buildWorkflow } from "@/lib/inngest/functions/build-workflow"; + +function invoke(serviceRevisionId: string, buildGroupId: string) { + const step = { + run: vi.fn(async (_name: string, operation: () => unknown) => operation()), + waitForEvent: vi.fn(), + }; + const handler = buildWorkflow as unknown as (input: { + event: { data: Record }; + step: typeof step; + }) => Promise; + return { + result: handler({ + event: { + data: { + buildId: `${buildGroupId}-amd64`, + serviceId: "service-1", + serviceRevisionId, + buildGroupId, + }, + }, + step, + }), + step, + }; +} + +function completedGroup( + serviceRevisionId: string, + buildGroupId: string, + image: string, +) { + const group = [ + { + id: `${buildGroupId}-amd64`, + status: "completed", + serviceRevisionId, + targetPlatform: "linux/amd64", + imageUri: `${image}-amd64`, + }, + { + id: `${buildGroupId}-arm64`, + status: "completed", + serviceRevisionId, + targetPlatform: "linux/arm64", + imageUri: `${image}-arm64`, + }, + ]; + mocks.queryResults.push( + group, + [ + { + status: "completed", + payload: JSON.stringify({ + serviceId: "service-1", + serviceRevisionId, + buildGroupId, + finalImageUri: image, + images: [`${image}-amd64`, `${image}-arm64`], + }), + }, + ], + group, + ); +} + +describe("revision-first build completion", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.queryResults.length = 0; + mocks.deployServiceRevisionInternal.mockResolvedValue({ + rolloutId: "rollout-1", + created: true, + }); + }); + + it("deploys each out-of-order build using its own immutable revision", async () => { + completedGroup("revision-new", "group-new", "registry/app:revision-new"); + await invoke("revision-new", "group-new").result; + + completedGroup("revision-old", "group-old", "registry/app:revision-old"); + await invoke("revision-old", "group-old").result; + + expect(mocks.deployServiceRevisionInternal).toHaveBeenNthCalledWith( + 1, + "service-1", + "revision-new", + "registry/app:revision-new", + ); + expect(mocks.deployServiceRevisionInternal).toHaveBeenNthCalledWith( + 2, + "service-1", + "revision-old", + "registry/app:revision-old", + ); + }); + + it("leaves a failed build revision without a rollout", async () => { + mocks.queryResults.push([ + { + id: "build-failed", + status: "failed", + serviceRevisionId: "revision-failed", + }, + ]); + + await expect( + invoke("revision-failed", "group-failed").result, + ).resolves.toEqual({ + status: "failed", + reason: "build_failed", + buildGroupId: "group-failed", + }); + expect(mocks.deployServiceRevisionInternal).not.toHaveBeenCalled(); + }); + + it("uses persisted completion when the build event was missed", async () => { + const image = "registry/app:revision-missed-build-event"; + const pending = { + id: "missed-build-amd64", + status: "building", + targetPlatform: "linux/amd64", + imageUri: null, + }; + const completed = { + ...pending, + status: "completed", + imageUri: `${image}-amd64`, + }; + mocks.queryResults.push( + [pending], + [completed], + [ + { + status: "completed", + payload: JSON.stringify({ + serviceId: "service-1", + serviceRevisionId: "revision-missed-build-event", + buildGroupId: "group-missed-build-event", + finalImageUri: image, + images: [`${image}-amd64`], + }), + }, + ], + [completed], + ); + + const { result, step } = invoke( + "revision-missed-build-event", + "group-missed-build-event", + ); + step.waitForEvent.mockResolvedValue(null); + await expect(result).resolves.toEqual( + expect.objectContaining({ status: "completed" }), + ); + expect(mocks.deployServiceRevisionInternal).toHaveBeenCalledWith( + "service-1", + "revision-missed-build-event", + image, + ); + }); + + it("uses persisted manifest completion when its event was missed", async () => { + const image = "registry/app:revision-missed-manifest-event"; + const group = [ + { + id: "missed-manifest-amd64", + status: "completed", + targetPlatform: "linux/amd64", + imageUri: `${image}-amd64`, + }, + ]; + mocks.queryResults.push( + group, + [], + [ + { + status: "completed", + payload: JSON.stringify({ + serviceId: "service-1", + serviceRevisionId: "revision-missed-manifest-event", + buildGroupId: "group-missed-manifest-event", + finalImageUri: image, + images: [`${image}-amd64`], + }), + }, + ], + group, + ); + + const { result, step } = invoke( + "revision-missed-manifest-event", + "group-missed-manifest-event", + ); + step.waitForEvent.mockResolvedValue(null); + await expect(result).resolves.toEqual( + expect.objectContaining({ status: "completed" }), + ); + expect(mocks.deployServiceRevisionInternal).toHaveBeenCalledWith( + "service-1", + "revision-missed-manifest-event", + image, + ); + }); + + it("rejects a manifest that omits a platform build", async () => { + const image = "registry/app:revision-incomplete-manifest"; + const group = [ + { + id: "incomplete-amd64", + status: "completed", + targetPlatform: "linux/amd64", + imageUri: `${image}-amd64`, + }, + { + id: "incomplete-arm64", + status: "completed", + targetPlatform: "linux/arm64", + imageUri: `${image}-arm64`, + }, + ]; + mocks.queryResults.push( + group, + [ + { + status: "completed", + payload: JSON.stringify({ + serviceId: "service-1", + serviceRevisionId: "revision-incomplete-manifest", + buildGroupId: "group-incomplete-manifest", + finalImageUri: image, + images: [`${image}-amd64`], + }), + }, + ], + group, + ); + + await expect( + invoke("revision-incomplete-manifest", "group-incomplete-manifest") + .result, + ).rejects.toThrow( + "Build manifest does not contain the complete platform group", + ); + expect(mocks.deployServiceRevisionInternal).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/deploy-service-revision.test.ts b/web/tests/deploy-service-revision.test.ts new file mode 100644 index 00000000..ef044af3 --- /dev/null +++ b/web/tests/deploy-service-revision.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createRolloutForServiceRevision: vi.fn(), + createRolloutWithServiceRevision: vi.fn(), + getService: vi.fn(), + replicaRows: [] as unknown[], + startMigrationInternal: vi.fn(), + triggerBuildInternal: vi.fn(), + send: vi.fn(), + createRolloutCreated: vi.fn((data, options) => ({ + name: "rollout/created", + data, + ...options, + })), +})); + +vi.mock("@/db", () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve(mocks.replicaRows)), + })), + })), + }, +})); +vi.mock("@/db/queries", () => ({ getService: mocks.getService })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/migrations", () => ({ + startMigrationInternal: mocks.startMigrationInternal, +})); +vi.mock("@/lib/service-revisions", () => ({ + createRolloutForServiceRevision: mocks.createRolloutForServiceRevision, + createRolloutWithServiceRevision: mocks.createRolloutWithServiceRevision, +})); +vi.mock("@/lib/inngest/client", () => ({ inngest: { send: mocks.send } })); +vi.mock("@/lib/trigger-build", () => ({ + triggerBuildInternal: mocks.triggerBuildInternal, +})); +vi.mock("@/lib/inngest/events", () => ({ + inngestEvents: { + rolloutCreated: { create: mocks.createRolloutCreated }, + }, +})); + +import { + deployServiceInternal, + deployServiceRevisionInternal, +} from "@/lib/deploy-service"; + +describe("revision rollout idempotency", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.send.mockResolvedValue(undefined); + mocks.replicaRows.length = 0; + mocks.startMigrationInternal.mockResolvedValue(undefined); + mocks.triggerBuildInternal.mockResolvedValue({ + buildId: null, + status: "queued", + }); + mocks.createRolloutWithServiceRevision.mockResolvedValue({ + rolloutId: "rollout-redeploy", + }); + mocks.createRolloutForServiceRevision + .mockResolvedValueOnce({ + rolloutId: "rollout-1", + created: true, + revision: { id: "revision-1" }, + }) + .mockResolvedValueOnce({ + rolloutId: "rollout-1", + created: false, + revision: { id: "revision-1" }, + }); + }); + + it("uses one idempotent rollout event identity for duplicate completion", async () => { + await deployServiceRevisionInternal( + "service-1", + "revision-1", + "registry/app:revision-1", + ); + await deployServiceRevisionInternal( + "service-1", + "revision-1", + "registry/app:revision-1", + ); + + expect(mocks.createRolloutForServiceRevision).toHaveBeenCalledTimes(2); + expect(mocks.createRolloutCreated).toHaveBeenCalledTimes(2); + expect(mocks.createRolloutCreated).toHaveBeenNthCalledWith( + 1, + { rolloutId: "rollout-1", serviceId: "service-1" }, + { id: "rollout-created-rollout-1" }, + ); + expect(mocks.createRolloutCreated).toHaveBeenNthCalledWith( + 2, + { rolloutId: "rollout-1", serviceId: "service-1" }, + { id: "rollout-created-rollout-1" }, + ); + expect(mocks.send).toHaveBeenCalledTimes(2); + expect(mocks.send.mock.calls[0]?.[0].id).toBe( + mocks.send.mock.calls[1]?.[0].id, + ); + }); + + it("retries dispatch for an existing rollout after a transient send failure", async () => { + mocks.send + .mockRejectedValueOnce(new Error("temporary Inngest outage")) + .mockResolvedValueOnce(undefined); + + await expect( + deployServiceRevisionInternal( + "service-1", + "revision-1", + "registry/app:revision-1", + ), + ).rejects.toThrow("temporary Inngest outage"); + await expect( + deployServiceRevisionInternal( + "service-1", + "revision-1", + "registry/app:revision-1", + ), + ).resolves.toEqual( + expect.objectContaining({ rolloutId: "rollout-1", created: false }), + ); + expect(mocks.send).toHaveBeenCalledTimes(2); + expect(mocks.send.mock.calls[0]?.[0].id).toBe( + mocks.send.mock.calls[1]?.[0].id, + ); + }); + + it("bases a GitHub redeployment on an explicit runtime revision", async () => { + mocks.getService.mockResolvedValue({ + id: "service-1", + sourceType: "github", + stateful: false, + }); + + await deployServiceInternal( + "service-1", + { type: "system" }, + { + runtimeBaseRevisionId: "revision-active", + }, + ); + + expect(mocks.createRolloutWithServiceRevision).toHaveBeenCalledWith( + "service-1", + { type: "system" }, + "revision-active", + ); + }); + + it("requires an explicit build or runtime base for a GitHub deployment", async () => { + mocks.getService.mockResolvedValue({ + id: "service-1", + sourceType: "github", + stateful: false, + }); + + await expect( + deployServiceInternal("service-1", { type: "system" }), + ).rejects.toThrow("GitHub deployment requires a build trigger"); + expect(mocks.createRolloutWithServiceRevision).not.toHaveBeenCalled(); + }); + + it("delegates an ordinary GitHub deployment to the build flow", async () => { + mocks.getService.mockResolvedValue({ + id: "service-1", + sourceType: "github", + stateful: false, + }); + + await deployServiceInternal( + "service-1", + { type: "system" }, + { + githubTrigger: "scheduled", + }, + ); + + expect(mocks.triggerBuildInternal).toHaveBeenCalledWith( + "service-1", + "scheduled", + { type: "system" }, + ); + expect(mocks.createRolloutWithServiceRevision).not.toHaveBeenCalled(); + }); + + it("starts stateful migration before dispatching a GitHub build", async () => { + mocks.getService.mockResolvedValue({ + id: "service-1", + sourceType: "github", + stateful: true, + lockedServerId: "server-old", + migrationStatus: null, + }); + mocks.replicaRows.push({ serverId: "server-new", replicas: 1 }); + + await expect( + deployServiceInternal( + "service-1", + { type: "system" }, + { + githubTrigger: "manual", + }, + ), + ).resolves.toEqual({ migrationStarted: true }); + expect(mocks.startMigrationInternal).toHaveBeenCalledWith( + "service-1", + "server-new", + { type: "system" }, + ); + expect(mocks.triggerBuildInternal).not.toHaveBeenCalled(); + }); + + it("keeps image service deployment behavior unchanged", async () => { + mocks.getService.mockResolvedValue({ + id: "service-1", + sourceType: "image", + stateful: false, + }); + + await deployServiceInternal("service-1", { type: "system" }); + + expect(mocks.createRolloutWithServiceRevision).toHaveBeenCalledWith( + "service-1", + { type: "system" }, + undefined, + ); + }); +}); diff --git a/web/tests/docker-image.test.ts b/web/tests/docker-image.test.ts new file mode 100644 index 00000000..3d7be37d --- /dev/null +++ b/web/tests/docker-image.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { validateDockerImageInternal } from "@/lib/docker-image"; + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("Docker image validation requests", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses fixed Docker Hub origins and encoded digest paths", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ token: "docker-token" })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const digest = `sha256:${"a".repeat(64)}`; + + await expect( + validateDockerImageInternal(`alpine@${digest}`), + ).resolves.toEqual({ valid: true }); + + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "https://auth.docker.io/token?service=registry.docker.io&scope=repository%3Alibrary%2Falpine%3Apull", + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual({ redirect: "error" }); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + `https://registry-1.docker.io/v2/library/alpine/manifests/sha256%3A${"a".repeat(64)}`, + { + redirect: "error", + headers: { + Authorization: "Bearer docker-token", + Accept: expect.any(String), + }, + }, + ); + }); + + it("validates nested Docker Hub repositories instead of treating them as registries", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + validateDockerImageInternal("owner/team/api:release-1"), + ).resolves.toEqual({ valid: true }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://hub.docker.com/v2/repositories/owner/team/api/tags/release-1", + { method: "GET", redirect: "error" }, + ); + }); + + it("uses fixed GHCR origins and accepts access_token responses", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ access_token: "ghcr-token" })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + validateDockerImageInternal("ghcr.io/acme/api:release"), + ).resolves.toEqual({ valid: true }); + + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "https://ghcr.io/token?scope=repository%3Aacme%2Fapi%3Apull", + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual({ redirect: "error" }); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://ghcr.io/v2/acme/api/manifests/release", + { + redirect: "error", + headers: { + Authorization: "Bearer ghcr-token", + Accept: expect.any(String), + }, + }, + ); + }); + + it.each([ + {}, + { token: "" }, + { token: " " }, + { token: 123 }, + { access_token: null }, + null, + [], + ])("does not request a manifest for malformed bearer token %#", async (tokenBody) => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(tokenBody)); + vi.stubGlobal("fetch", fetchMock); + + await expect( + validateDockerImageInternal("ghcr.io/acme/api:release"), + ).resolves.toEqual({ + valid: false, + error: "Image not found on GitHub Container Registry", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("handles a non-JSON token response as an authentication failure", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("not json", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + validateDockerImageInternal(`alpine@sha256:${"a".repeat(64)}`), + ).resolves.toEqual({ + valid: false, + error: "Failed to authenticate with Docker Hub", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + "docker.io.evil.example/owner/api:tag", + "docker.io:443/owner/api:tag", + "ghcr.io.evil.example/owner/api:tag", + "localhost:5000/owner/api:tag", + "REGISTRY/owner/api:tag", + ])("does not contact unsupported registry %s", async (image) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(validateDockerImageInternal(image)).resolves.toEqual({ + valid: true, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + "docker.io/acme//api:tag", + "docker.io/acme\\evil/api:tag", + "docker.io/acme/api?query:tag", + "docker.io/acme/%2f:tag", + "ghcr.io/acme/api#fragment:tag", + ])("rejects structural URL characters in %s", async (image) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(validateDockerImageInternal(image)).resolves.toMatchObject({ + valid: false, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/expected-state.test.ts b/web/tests/expected-state.test.ts index fcc15421..864dda64 100644 --- a/web/tests/expected-state.test.ts +++ b/web/tests/expected-state.test.ts @@ -27,8 +27,9 @@ describe("expected-state pure builders", () => { serviceId, revisionId: `rev_${serviceId}`, specification: { - schemaVersion: 1, + schemaVersion: 2, image: "nginx", + source: { type: "image", image: "nginx" }, hostname: serviceId, stateful: false, serverless: { diff --git a/web/tests/github-webhook.test.ts b/web/tests/github-webhook.test.ts index 9d758563..eca122e4 100644 --- a/web/tests/github-webhook.test.ts +++ b/web/tests/github-webhook.test.ts @@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => { updateGitHubDeploymentStatus: vi.fn(), send: vi.fn(), createBuildTrigger: vi.fn(), + triggerResolvedBuildInternal: vi.fn(), }; }); @@ -45,6 +46,9 @@ vi.mock("@/lib/inngest/events", () => ({ buildTrigger: { create: mocks.createBuildTrigger }, }, })); +vi.mock("@/lib/trigger-build", () => ({ + triggerResolvedBuildInternal: mocks.triggerResolvedBuildInternal, +})); import { POST } from "@/app/api/webhooks/github/route"; @@ -57,6 +61,7 @@ function linkedService({ autoDeploy = true, sourceType = "github", deletedAt = null, + rootDir = "", }: { serviceId: string; name?: string; @@ -64,6 +69,7 @@ function linkedService({ autoDeploy?: boolean; sourceType?: "github" | "image"; deletedAt?: Date | null; + rootDir?: string; }) { return { githubRepo: { @@ -82,6 +88,7 @@ function linkedService({ name, sourceType, deletedAt, + githubRootDir: rootDir, }, }; } @@ -129,12 +136,18 @@ describe("GitHub push webhook", () => { data, ...options, })); + mocks.triggerResolvedBuildInternal.mockReset(); + mocks.triggerResolvedBuildInternal.mockResolvedValue({ status: "queued" }); }); it("queues every active service linked to the pushed repository and branch", async () => { mocks.queryResults.push( [ - linkedService({ serviceId: "service-a", name: "web" }), + linkedService({ + serviceId: "service-a", + name: "web", + rootDir: "apps/web", + }), linkedService({ serviceId: "service-b", name: "web" }), ], [], @@ -154,24 +167,24 @@ describe("GitHub push webhook", () => { { serviceId: "service-b", status: "queued" }, ], }); - expect(mocks.send).toHaveBeenCalledTimes(2); - expect(mocks.createBuildTrigger).toHaveBeenNthCalledWith( + expect(mocks.triggerResolvedBuildInternal).toHaveBeenCalledTimes(2); + expect(mocks.triggerResolvedBuildInternal).toHaveBeenNthCalledWith( 1, + "service-a", expect.objectContaining({ - serviceId: "service-a", - githubRepoId: "link-service-a", githubDeploymentId: 1001, + expectedRepository: "https://github.com/techulus/cloud", + expectedBranch: "main", + idempotencyKey: `github-push:link-service-a:${COMMIT_SHA}`, }), - { id: `github-push:link-service-a:${COMMIT_SHA}` }, ); - expect(mocks.createBuildTrigger).toHaveBeenNthCalledWith( + expect(mocks.triggerResolvedBuildInternal).toHaveBeenNthCalledWith( 2, + "service-b", expect.objectContaining({ - serviceId: "service-b", - githubRepoId: "link-service-b", githubDeploymentId: 1002, + idempotencyKey: `github-push:link-service-b:${COMMIT_SHA}`, }), - { id: `github-push:link-service-b:${COMMIT_SHA}` }, ); expect(mocks.createGitHubDeployment).toHaveBeenNthCalledWith( 1, @@ -212,7 +225,7 @@ describe("GitHub push webhook", () => { reason: "branch mismatch: main != staging", }, ]); - expect(mocks.send).toHaveBeenCalledTimes(1); + expect(mocks.triggerResolvedBuildInternal).toHaveBeenCalledTimes(1); }); it("does not let an ineligible or previously built service suppress another link", async () => { @@ -259,9 +272,9 @@ describe("GitHub push webhook", () => { }, { serviceId: "service-eligible", status: "queued" }, ]); - expect(mocks.send).toHaveBeenCalledTimes(1); - expect(mocks.createBuildTrigger).toHaveBeenCalledWith( - expect.objectContaining({ serviceId: "service-eligible" }), + expect(mocks.triggerResolvedBuildInternal).toHaveBeenCalledTimes(1); + expect(mocks.triggerResolvedBuildInternal).toHaveBeenCalledWith( + "service-eligible", expect.any(Object), ); }); @@ -281,7 +294,7 @@ describe("GitHub push webhook", () => { .mockRejectedValueOnce(new Error("GitHub unavailable")) .mockResolvedValueOnce(1002) .mockResolvedValueOnce(1003); - mocks.send + mocks.triggerResolvedBuildInternal .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error("Inngest unavailable")) .mockResolvedValueOnce(undefined); @@ -302,18 +315,17 @@ describe("GitHub push webhook", () => { { serviceId: "service-later", status: "queued" }, ], }); - expect(mocks.send).toHaveBeenCalledTimes(3); - expect(mocks.createBuildTrigger).toHaveBeenNthCalledWith( + expect(mocks.triggerResolvedBuildInternal).toHaveBeenCalledTimes(3); + expect(mocks.triggerResolvedBuildInternal).toHaveBeenNthCalledWith( 1, + "service-deployment-error", expect.objectContaining({ - serviceId: "service-deployment-error", githubDeploymentId: undefined, }), - expect.any(Object), ); - expect(mocks.createBuildTrigger).toHaveBeenNthCalledWith( + expect(mocks.triggerResolvedBuildInternal).toHaveBeenNthCalledWith( 3, - expect.objectContaining({ serviceId: "service-later" }), + "service-later", expect.any(Object), ); }); diff --git a/web/tests/github.test.ts b/web/tests/github.test.ts index 45945513..4a2436b5 100644 --- a/web/tests/github.test.ts +++ b/web/tests/github.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { isFullCommitSha } from "@/lib/github"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isFullCommitSha, resolveGitHubCommit } from "@/lib/github"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); describe("GitHub commit SHA validation", () => { it("accepts only full hexadecimal commit SHAs", () => { @@ -16,3 +20,43 @@ describe("GitHub commit SHA validation", () => { ); }); }); + +describe("public GitHub branch resolution", () => { + it("resolves a branch to one exact immutable commit", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify([ + { + sha: "0123456789ABCDEF0123456789ABCDEF01234567", + author: { login: "octocat" }, + commit: { + message: "Ship it", + author: { + name: "Octo Cat", + date: "2026-07-20T00:00:00Z", + }, + }, + }, + ]), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + resolveGitHubCommit("techulus/cloud", "feature/public api"), + ).resolves.toMatchObject({ + sha: "0123456789ABCDEF0123456789ABCDEF01234567", + message: "Ship it", + author: "octocat", + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/repos/techulus/cloud/commits?sha=feature%2Fpublic%20api&per_page=1", + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.anything(), + }), + }), + ); + }); +}); diff --git a/web/tests/log-routes.test.ts b/web/tests/log-routes.test.ts index a50131d8..b8e9261c 100644 --- a/web/tests/log-routes.test.ts +++ b/web/tests/log-routes.test.ts @@ -7,7 +7,6 @@ describe("log routes", () => { vi.doUnmock("next/headers"); vi.doUnmock("@/lib/api-auth"); vi.doUnmock("@/lib/auth"); - vi.doUnmock("@/lib/cli-service"); vi.doUnmock("@/lib/victoria-logs"); }); @@ -120,33 +119,6 @@ describe("log routes", () => { message: "Failed to query deployment logs", }); }); - - it("rejects an injected manifest cursor before resolving the service", async () => { - vi.resetModules(); - const getManifestStatus = vi.fn(); - const queryLogsByService = vi.fn(); - vi.doMock("@/lib/api-auth", () => ({ - requireRequestRole: async () => ({ ok: true }), - })); - vi.doMock("@/lib/cli-service", () => ({ getManifestStatus })); - vi.doMock("@/lib/victoria-logs", () => ({ - isLoggingEnabled: () => true, - queryLogsByService, - })); - const { GET } = await import("@/app/api/v1/manifest/logs/route"); - const url = new URL("http://localhost/api/v1/manifest/logs"); - url.searchParams.set("project", "project-1"); - url.searchParams.set("environment", "production"); - url.searchParams.set("service", "web"); - url.searchParams.set("after", "2026-07-10T01:02:03Z | stats count()"); - - const response = await GET(new Request(url)); - - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ error: "Invalid log cursor" }); - expect(getManifestStatus).not.toHaveBeenCalled(); - expect(queryLogsByService).not.toHaveBeenCalled(); - }); }); async function loadServiceLogsRoute( diff --git a/web/tests/public-api-auth.test.ts b/web/tests/public-api-auth.test.ts new file mode 100644 index 00000000..5d83841d --- /dev/null +++ b/web/tests/public-api-auth.test.ts @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const dbUser = { + id: "user-1", + name: "Alice", + email: "alice@example.com", + banned: false, + banExpires: null as Date | null, + }; + return { + createApiKey: vi.fn(), + verifyApiKey: vi.fn(), + getSession: vi.fn(), + getUserRole: vi.fn(), + dbUser, + db: { + select: vi.fn(() => { + const query = { + from: vi.fn(() => query), + where: vi.fn(() => query), + limit: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve([dbUser]).then(resolve, reject), + }; + return query; + }), + }, + }; +}); + +vi.mock("@/db", () => ({ db: mocks.db })); + +vi.mock("@/lib/auth", () => ({ + auth: { + api: { + createApiKey: mocks.createApiKey, + verifyApiKey: mocks.verifyApiKey, + getSession: mocks.getSession, + }, + }, +})); + +vi.mock("@/lib/members", () => ({ + AdminNotConfiguredError: class AdminNotConfiguredError extends Error { + code = "ADMIN_NOT_CONFIGURED"; + }, + getUserRole: mocks.getUserRole, + hasAnyRole: (role: string, allowedRoles: string[]) => + allowedRoles.includes(role), +})); + +import { POST as createApiKey } from "@/app/api/v1/api-keys/route"; +import { requireApiKeyDeveloperRole, requireApiKeyRole } from "@/lib/api-auth"; + +const session = { + user: { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }, + session: { + id: "session-1", + expiresAt: new Date("2027-01-01T00:00:00Z"), + }, +}; + +describe("public API authentication", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSession.mockResolvedValue(session); + mocks.verifyApiKey.mockResolvedValue({ + valid: true, + error: null, + key: { + id: "key-1", + referenceId: "user-1", + expiresAt: null, + }, + }); + Object.assign(mocks.dbUser, { + id: "user-1", + name: "Alice", + email: "alice@example.com", + banned: false, + banExpires: null, + }); + mocks.getUserRole.mockResolvedValue("reader"); + mocks.createApiKey.mockResolvedValue({ + id: "key-1", + key: "tcl_secret", + name: "CLI", + }); + }); + + it("rejects cookie and bearer credentials without X-API-Key", async () => { + const credentialHeaders: HeadersInit[] = [ + { cookie: "better-auth.session_token=session" }, + { authorization: "Bearer device-token" }, + ]; + for (const headers of credentialHeaders) { + const result = await requireApiKeyRole( + new Request("https://cloud.test/api/v1/projects", { headers }), + ["admin", "developer", "reader"], + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.response.status).toBe(401); + expect(await result.response.json()).toEqual({ + message: "Unauthorized", + code: "UNAUTHORIZED", + }); + } + } + expect(mocks.verifyApiKey).not.toHaveBeenCalled(); + }); + + it("validates only X-API-Key and never falls back to a cookie or bearer token", async () => { + const result = await requireApiKeyRole( + new Request("https://cloud.test/api/v1/me", { + headers: { + "x-api-key": "tcl_secret", + cookie: "better-auth.session_token=session", + authorization: "Bearer device-token", + "user-agent": "tc/test", + }, + }), + ["reader"], + ); + + expect(result.ok).toBe(true); + expect(mocks.verifyApiKey).toHaveBeenCalledWith({ + body: { key: "tcl_secret" }, + }); + expect(mocks.getSession).not.toHaveBeenCalled(); + }); + + it("does not accept a valid cookie when the supplied API key is invalid", async () => { + mocks.verifyApiKey.mockResolvedValue({ + valid: false, + error: { message: "Invalid API key", code: "INVALID_API_KEY" }, + key: null, + }); + const result = await requireApiKeyRole( + new Request("https://cloud.test/api/v1/projects", { + headers: { + "x-api-key": "invalid", + cookie: "better-auth.session_token=valid-session", + }, + }), + ["reader"], + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.response.status).toBe(401); + expect(await result.response.json()).toEqual({ + message: "Unauthorized", + code: "UNAUTHORIZED", + }); + } + }); + + it("allows readers to read but rejects them from writes", async () => { + const request = new Request("https://cloud.test/api/v1/projects/p", { + headers: { "x-api-key": "tcl_secret" }, + }); + const read = await requireApiKeyRole(request, [ + "admin", + "developer", + "reader", + ]); + const write = await requireApiKeyDeveloperRole(request); + + expect(read.ok).toBe(true); + expect(write.ok).toBe(false); + if (!write.ok) { + expect(write.response.status).toBe(403); + expect(await write.response.json()).toEqual({ + message: "Forbidden", + code: "FORBIDDEN", + }); + } + }); + + it("allows bearer or browser sessions to mint a key", async () => { + const credentialHeaders: HeadersInit[] = [ + { + authorization: "Bearer device-token", + "content-type": "application/json", + }, + { + cookie: "better-auth.session_token=session", + "content-type": "application/json", + }, + ]; + for (const headers of credentialHeaders) { + const response = await createApiKey( + new Request("https://cloud.test/api/v1/api-keys", { + method: "POST", + headers, + body: JSON.stringify({ name: "CLI" }), + }), + ); + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ + apiKey: "tcl_secret", + keyId: "key-1", + name: "CLI", + }); + } + expect(mocks.createApiKey).toHaveBeenCalledTimes(2); + }); + + it("does not let an API key mint another API key", async () => { + const response = await createApiKey( + new Request("https://cloud.test/api/v1/api-keys", { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": "tcl_secret", + }, + body: JSON.stringify({ name: "CLI" }), + }), + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + message: "API keys cannot create API keys", + code: "API_KEY_AUTH_FORBIDDEN", + }); + expect(mocks.getSession).not.toHaveBeenCalled(); + expect(mocks.verifyApiKey).not.toHaveBeenCalled(); + expect(mocks.createApiKey).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/public-api-configuration.test.ts b/web/tests/public-api-configuration.test.ts new file mode 100644 index 00000000..a255c6f6 --- /dev/null +++ b/web/tests/public-api-configuration.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const rows: unknown[][] = []; + const select = vi.fn(() => { + const result = rows.shift() ?? []; + const query = { + from: vi.fn(() => query), + innerJoin: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + limit: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: (resolve: (value: unknown[]) => unknown) => + Promise.resolve(result).then(resolve), + }; + return query; + }); + return { rows, select }; +}); + +vi.mock("@/db", () => ({ db: { select: mocks.select } })); + +import { safeConfiguration } from "@/lib/public-api"; + +describe("public API configuration state", () => { + beforeEach(() => { + mocks.rows.length = 0; + mocks.select.mockClear(); + }); + + it("does not report a derived default hostname as a pending change", async () => { + mocks.rows.push( + [], + [], + [], + [{ serverId: "server-1", serverName: "Sydney", count: 1 }], + [{ id: "deployment-1", revisionId: "revision-1" }], + [ + { + specification: { + schemaVersion: 2, + image: "nginx:1.27", + source: { type: "image", image: "nginx:1.27" }, + hostname: "hello-service", + stateful: false, + serverless: { + enabled: false, + sleepAfterSeconds: 300, + wakeTimeoutSeconds: 300, + }, + healthCheck: null, + startCommand: null, + resourceLimits: { cpuCores: null, memoryMb: null }, + placements: [{ serverId: "server-1", count: 1 }], + ports: [], + secrets: [], + volumes: [], + }, + }, + ], + ); + + const configuration = await safeConfiguration({ + id: "service-1", + name: "Hello Service", + sourceType: "image", + image: "nginx:1.27", + hostname: null, + stateful: false, + healthCheckCmd: null, + healthCheckInterval: 10, + healthCheckTimeout: 5, + healthCheckRetries: 3, + healthCheckStartPeriod: 30, + startCommand: null, + resourceCpuLimit: null, + resourceMemoryLimitMb: null, + serverlessEnabled: false, + serverlessSleepAfterSeconds: 300, + serverlessWakeTimeoutSeconds: 300, + deploymentSchedule: null, + backupEnabled: false, + backupSchedule: null, + } as never); + + expect(configuration.current.hostname).toBeNull(); + expect(configuration.active?.hostname).toBe("hello-service"); + expect(configuration.hasPendingChanges).toBe(false); + expect(configuration.changes).toEqual([]); + }); +}); diff --git a/web/tests/public-api-long-poll.test.ts b/web/tests/public-api-long-poll.test.ts new file mode 100644 index 00000000..a2fd22e9 --- /dev/null +++ b/web/tests/public-api-long-poll.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; +import { + decodeServiceLogCursor, + encodeServiceLogCursor, + longPollLogs, + nextServiceLogCursor, +} from "@/lib/public-api-routes"; + +describe("public API log long polling", () => { + it("returns as soon as records appear", async () => { + vi.useFakeTimers(); + const query = vi + .fn() + .mockResolvedValueOnce({ logs: [], hasMore: false }) + .mockResolvedValueOnce({ logs: [{ message: "ready" }], hasMore: false }); + const resultPromise = longPollLogs(query, { + waitMs: 10_000, + intervalMs: 100, + }); + await vi.advanceTimersByTimeAsync(100); + await expect(resultPromise).resolves.toEqual({ + logs: [{ message: "ready" }], + hasMore: false, + }); + expect(query).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it("returns the last empty result at the timeout without an extra query", async () => { + vi.useFakeTimers(); + const empty = { logs: [], hasMore: false, marker: "initial" }; + const query = vi.fn().mockResolvedValue(empty); + const resultPromise = longPollLogs(query, { + waitMs: 250, + intervalMs: 100, + }); + + await vi.advanceTimersByTimeAsync(250); + + await expect(resultPromise).resolves.toBe(empty); + expect(query).toHaveBeenCalledTimes(3); + vi.useRealTimers(); + }); + + it("does not query when the request is already aborted", async () => { + const controller = new AbortController(); + const query = vi.fn().mockResolvedValue({ logs: [], hasMore: false }); + controller.abort(new DOMException("Aborted", "AbortError")); + const resultPromise = longPollLogs(query, { + waitMs: 10_000, + signal: controller.signal, + }); + await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" }); + expect(query).not.toHaveBeenCalled(); + }); + + it("round-trips an opaque tuple cursor and rejects malformed values", () => { + const value = { + v: 1 as const, + t: "2026-07-20T12:34:56.123456789Z", + e: `e${"1784546100123456789"}${"a".repeat(26)}`, + }; + const cursor = encodeServiceLogCursor(value); + + expect(decodeServiceLogCursor(cursor)).toEqual(value); + for (const invalid of [ + "not+base64url", + Buffer.from( + JSON.stringify({ ...value, t: "2026-02-31T00:00:00Z" }), + ).toString("base64url"), + Buffer.from( + JSON.stringify({ ...value, e: "uuid-is-not-sortable" }), + ).toString("base64url"), + ]) { + expect(decodeServiceLogCursor(invalid)).toBeNull(); + } + }); + + it("anchors an unidentified initial page to its last log timestamp", () => { + const timestamp = "2026-07-20T12:34:56.123456789Z"; + const cursor = nextServiceLogCursor( + [ + { + _msg: "legacy agent log", + _time: timestamp, + service_id: "service-1", + }, + ], + null, + ); + + expect(decodeServiceLogCursor(cursor)).toEqual({ + v: 1, + t: timestamp, + e: "", + }); + }); +}); diff --git a/web/tests/public-api-pagination.test.ts b/web/tests/public-api-pagination.test.ts new file mode 100644 index 00000000..9b44b716 --- /dev/null +++ b/web/tests/public-api-pagination.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { + decodeTimestampCursor, + namedPage, + nextNamedCursor, + nextTimestampCursor, + timestampPage, +} from "@/lib/public-api-pagination"; + +function pageUrl(parameters = "") { + return new URL(`https://cloud.example/api/v1/projects${parameters}`); +} + +describe("public API named keyset pagination", () => { + it("round-trips the final visible name and id, including equal-name ties", () => { + const items = [ + { name: "alpha", id: "project-1" }, + { name: "same", id: "project-2" }, + { name: "same", id: "project-3" }, + ]; + const cursor = nextNamedCursor(items, 2); + + expect(cursor).toEqual(expect.any(String)); + expect(namedPage(pageUrl(`?limit=2&cursor=${cursor}`))).toEqual({ + limit: 2, + cursor: { name: "same", id: "project-2" }, + }); + }); + + it("only emits a cursor when an extra row proves another page exists", () => { + expect(nextNamedCursor([{ name: "alpha", id: "1" }], 1)).toBeNull(); + expect( + nextNamedCursor( + [ + { name: "alpha", id: "1" }, + { name: "beta", id: "2" }, + ], + 1, + ), + ).toEqual(expect.any(String)); + }); + + it.each([ + "0", + "101", + "1.5", + "NaN", + "", + ])("rejects an invalid limit of %j", (limit) => { + expect(() => namedPage(pageUrl(`?limit=${limit}`))).toThrow( + "limit must be an integer from 1 to 100", + ); + }); + + it("accepts limit boundaries and defaults to 100", () => { + expect(namedPage(pageUrl()).limit).toBe(100); + expect(namedPage(pageUrl("?limit=1")).limit).toBe(1); + expect(namedPage(pageUrl("?limit=100")).limit).toBe(100); + }); + + it.each([ + "not+base64url", + Buffer.from("not json").toString("base64url"), + Buffer.from(JSON.stringify({ name: "alpha" })).toString("base64url"), + Buffer.from(JSON.stringify({ name: "alpha", id: "" })).toString( + "base64url", + ), + "a".repeat(2049), + ])("rejects malformed cursors", (cursor) => { + expect(() => namedPage(pageUrl(`?cursor=${cursor}`))).toThrow( + "Invalid cursor", + ); + }); +}); + +describe("public API timestamp keyset pagination", () => { + it("preserves the database timestamp precision in emitted cursors", () => { + const cursor = nextTimestampCursor( + [ + { + id: "build-1", + cursorCreatedAt: "2026-07-20 12:00:00.123456+00", + }, + { + id: "build-2", + cursorCreatedAt: "2026-07-20 12:00:00.123400+00", + }, + ], + 1, + ); + + expect(decodeTimestampCursor(cursor)).toEqual({ + id: "build-1", + createdAt: "2026-07-20 12:00:00.123456+00", + }); + }); + + it("round-trips valid PostgreSQL and RFC 3339 timestamp forms", () => { + for (const createdAt of [ + "2026-07-20 12:00:00.123456+00", + "2026-07-20T12:00:00.123456Z", + "2026-07-20T12:00:00-04:30", + ]) { + const cursor = Buffer.from( + JSON.stringify({ id: "rollout-1", createdAt }), + ).toString("base64url"); + expect( + timestampPage(pageUrl(`?limit=1&cursor=${cursor}`)).cursor, + ).toEqual({ id: "rollout-1", createdAt }); + } + }); + + it.each([ + "2026-02-29T00:00:00Z", + "2026-02-31T00:00:00Z", + "0000-01-01T00:00:00Z", + "2026-01-01T24:00:00Z", + "2026-01-01T00:00:60Z", + "2026-01-01T00:00:00+14:01", + ])("rejects an invalid timestamp without sending it to PostgreSQL: %s", (createdAt) => { + const cursor = Buffer.from( + JSON.stringify({ id: "rollout-1", createdAt }), + ).toString("base64url"); + expect(() => timestampPage(pageUrl(`?cursor=${cursor}`))).toThrow( + "Invalid cursor", + ); + }); + + it.each([ + "not+base64url", + Buffer.from("not json").toString("base64url"), + "a".repeat(2049), + ])("rejects a malformed timestamp cursor", (cursor) => { + expect(() => timestampPage(pageUrl(`?cursor=${cursor}`))).toThrow( + "Invalid cursor", + ); + }); +}); diff --git a/web/tests/public-api-revisions-auth.test.ts b/web/tests/public-api-revisions-auth.test.ts new file mode 100644 index 00000000..57daeac2 --- /dev/null +++ b/web/tests/public-api-revisions-auth.test.ts @@ -0,0 +1,107 @@ +import { expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + requireApiKeyRole: vi.fn(async (request: Request) => + request.headers.get("x-api-key") === "tcl_secret" + ? { + ok: true as const, + session: { + user: { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }, + session: { + id: "key-1", + expiresAt: new Date("2027-01-01T00:00:00Z"), + }, + }, + } + : { + ok: false as const, + response: Response.json( + { message: "Unauthorized", code: "UNAUTHORIZED" }, + { status: 401 }, + ), + }, + ), + requireRequestSession: vi.fn(async () => ({ + ok: false as const, + response: Response.json( + { message: "Unauthorized", code: "UNAUTHORIZED" }, + { status: 401 }, + ), + })), + findNestedService: vi.fn(async () => ({ id: "service-1" })), + queryServiceRevisionChangelog: vi.fn(async () => ({ + revisions: [ + { + id: "revision-1", + createdAt: "2026-07-21T00:00:00.000Z", + actor: null, + comparison: { + kind: "changes" as const, + changes: [ + { field: "Image", from: "app:v1", to: "app:v2" }, + { field: "Secret", from: "TOKEN", to: "TOKEN (updated)" }, + ], + }, + rollout: null, + }, + ], + nextCursor: null, + })), +})); + +vi.mock("@/lib/api-auth", () => ({ + requireApiKeyRole: mocks.requireApiKeyRole, + requireApiKeyDeveloperRole: vi.fn(), + requireRequestSession: mocks.requireRequestSession, +})); + +vi.mock("@/lib/public-api", async (importOriginal) => ({ + ...(await importOriginal()), + findNestedService: mocks.findNestedService, +})); + +vi.mock("@/lib/service-revision-changelog", () => ({ + queryServiceRevisionChangelog: mocks.queryServiceRevisionChangelog, +})); + +import { GET } from "@/app/api/v1/projects/[projectId]/environments/[environmentId]/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" } }, + ), + { + params: Promise.resolve({ + projectId: "project-1", + environmentId: "environment-1", + serviceId: "service-1", + }), + }, + ); + + if (!response) throw new Error("Expected a public API response"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + revisions: [ + { + id: "revision-1", + createdAt: "2026-07-21T00:00:00.000Z", + actor: null, + comparison: { + kind: "changes", + changes: [{ field: "Image", from: "app:v1", to: "app:v2" }], + }, + rollout: null, + }, + ], + nextCursor: null, + }); + expect(mocks.requireApiKeyRole).toHaveBeenCalledOnce(); + expect(mocks.requireRequestSession).not.toHaveBeenCalled(); +}); diff --git a/web/tests/public-api-source.test.ts b/web/tests/public-api-source.test.ts new file mode 100644 index 00000000..a31698e4 --- /dev/null +++ b/web/tests/public-api-source.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalGitHubRepository, + configurationPatchSchema, + isSafeRepositoryRoot, + publicSourceSchema, +} from "@/lib/public-api"; + +describe("public API GitHub sources", () => { + it.each([ + [ + " https://github.com/Techulus/Cloud.git/ ", + "https://github.com/Techulus/Cloud", + ], + [ + "https://github.com/owner/repository", + "https://github.com/owner/repository", + ], + ])("canonicalizes %s", (input, expected) => { + expect(canonicalGitHubRepository(input)).toBe(expected); + }); + + it.each([ + "http://github.com/owner/repository", + "https://gitHub.example/owner/repository", + "https://user:password@github.com/owner/repository", + "https://github.com:8443/owner/repository", + "https://github.com/owner/repository/issues", + "https://github.com/owner/repository?tab=readme", + "git@github.com:owner/repository.git", + ])("rejects non-canonicalizable repository URL %s", (url) => { + expect(() => canonicalGitHubRepository(url)).toThrow(); + }); + + it.each([ + "app", + "packages/web", + "packages\\web", + ".", + "a/./b", + ])("accepts safe repository root %s", (rootDir) => + expect(isSafeRepositoryRoot(rootDir)).toBe(true)); + + it.each([ + "", + "/etc", + "C:\\repo", + "D:/repo", + "\\\\server\\share", + "../outside", + "app/../../outside", + "app\\..\\outside", + ])("rejects unsafe repository root %j", (rootDir) => { + expect(isSafeRepositoryRoot(rootDir)).toBe(false); + }); + + it("normalizes a valid GitHub source", () => { + expect( + publicSourceSchema.parse({ + type: "github", + repository: " https://github.com/owner/repository.git ", + branch: " feature/test ", + rootDir: " packages\\web ", + }), + ).toEqual({ + type: "github", + repository: "https://github.com/owner/repository", + branch: "feature/test", + rootDir: "packages/web", + }); + }); + + it("requires a nonblank GitHub branch", () => { + expect( + configurationPatchSchema.safeParse({ + source: { + type: "github", + repository: "https://github.com/owner/repository", + branch: " ", + }, + }).success, + ).toBe(false); + }); + + it.each([ + { + type: "image", + image: "registry.example/app:latest", + repository: "https://github.com/owner/repository", + }, + { + type: "github", + repository: "https://github.com/owner/repository", + branch: "main", + image: "registry.example/app:latest", + }, + ])("rejects mixed source fields", (source) => { + expect(configurationPatchSchema.safeParse({ source }).success).toBe(false); + }); + + it("distinguishes omitted rootDir from explicit null", () => { + const omitted = publicSourceSchema.parse({ + type: "github", + repository: "https://github.com/owner/repository", + branch: "main", + }); + const cleared = publicSourceSchema.parse({ + ...omitted, + rootDir: null, + }); + + expect(omitted).not.toHaveProperty("rootDir"); + expect(cleared).toHaveProperty("rootDir", null); + }); +}); diff --git a/web/tests/service-config.test.ts b/web/tests/service-config.test.ts index 605bffaa..f6f65794 100644 --- a/web/tests/service-config.test.ts +++ b/web/tests/service-config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildCurrentConfig, type DeployedConfig, diffConfigs, getCurrentServerlessConfig, @@ -43,8 +44,9 @@ describe("service config", () => { it("converts an immutable revision for pending-change comparisons", () => { const config = revisionSpecToDeployedConfig( { - schemaVersion: 1, + schemaVersion: 2, image: "nginx", + source: { type: "image", image: "nginx" }, hostname: "api", stateful: false, serverless: { @@ -68,6 +70,144 @@ describe("service config", () => { ]); }); + it("does not report revision-scoped GitHub artifacts as pending image changes", () => { + const currentSource = { + type: "github" as const, + repository: "https://github.com/acme/api", + branch: "main", + rootDir: "apps/api", + }; + const current = buildCurrentConfig( + { + image: "registry.test/project/service:latest", + hostname: "api", + healthCheckCmd: null, + healthCheckInterval: null, + healthCheckTimeout: null, + healthCheckRetries: null, + healthCheckStartPeriod: null, + startCommand: null, + resourceCpuLimit: null, + resourceMemoryLimitMb: null, + replicas: 0, + stateful: false, + serverlessEnabled: false, + serverlessSleepAfterSeconds: 300, + serverlessWakeTimeoutSeconds: 300, + }, + [], + [], + [], + [], + currentSource, + ); + + for (const [image, commitSha] of [ + [ + "registry.test/project/service:revision-first", + "1111111111111111111111111111111111111111", + ], + [ + "registry.test/project/service:revision-second", + "2222222222222222222222222222222222222222", + ], + ] as const) { + const active = revisionSpecToDeployedConfig( + { + schemaVersion: 2, + image, + source: { + ...currentSource, + repositoryId: 101, + commitSha, + authentication: { + type: "github_app", + installationId: 202, + }, + }, + hostname: "api", + stateful: false, + serverless: { + enabled: false, + sleepAfterSeconds: 300, + wakeTimeoutSeconds: 300, + }, + healthCheck: null, + startCommand: null, + resourceLimits: { cpuCores: null, memoryMb: null }, + placements: [], + ports: [], + secrets: [], + volumes: [], + }, + {}, + ); + + expect(active.source).toEqual(currentSource); + expect(diffConfigs(active, current)).toEqual([]); + } + }); + + it("reports real GitHub source changes as build-affecting", () => { + const changes = diffConfigs( + deployedConfig({ + source: { + type: "github", + repository: "https://github.com/acme/api", + branch: "main", + rootDir: null, + }, + }), + deployedConfig({ + source: { + type: "github", + repository: "https://github.com/acme/platform", + branch: "production", + rootDir: "apps/api", + }, + }), + ); + + expect(changes).toEqual([ + { + field: "GitHub repository", + from: "https://github.com/acme/api", + to: "https://github.com/acme/platform", + requiresBuild: true, + }, + { + field: "GitHub branch", + from: "main", + to: "production", + requiresBuild: true, + }, + { + field: "GitHub root directory", + from: "(repository root)", + to: "apps/api", + requiresBuild: true, + }, + ]); + expect(hasBuildAffectingChanges(changes)).toBe(true); + }); + + it("continues to report configured image changes", () => { + expect( + diffConfigs( + deployedConfig({ + source: { type: "image", image: "nginx:1.27" }, + }), + deployedConfig({ + source: { type: "image", image: "nginx:1.28" }, + }), + ), + ).toContainEqual({ + field: "Image", + from: "nginx:1.27", + to: "nginx:1.28", + }); + }); + it("does not report null and omitted resource limits as pending", () => { const deployed = deployedConfig({ resourceLimits: { cpuCores: null, memoryMb: null }, diff --git a/web/tests/service-revision-build.test.ts b/web/tests/service-revision-build.test.ts new file mode 100644 index 00000000..3539c9d5 --- /dev/null +++ b/web/tests/service-revision-build.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ServiceRevisionSpec } from "@/lib/service-revision-spec"; + +const mocks = vi.hoisted(() => { + const selectResults: unknown[][] = []; + const insertedValues: unknown[] = []; + const returningResults: unknown[][] = []; + function selectQuery(result: unknown[]) { + const query = { + from: vi.fn(() => query), + where: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return query; + } + function insertQuery() { + const result = returningResults.shift() ?? []; + const query = { + values: vi.fn((value: unknown) => { + insertedValues.push(value); + return query; + }), + onConflictDoNothing: vi.fn(() => query), + returning: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return query; + } + const tx = { + select: vi.fn(() => selectQuery(selectResults.shift() ?? [])), + insert: vi.fn(() => insertQuery()), + }; + return { + selectResults, + insertedValues, + returningResults, + tx, + db: { + transaction: vi.fn((operation: (transaction: typeof tx) => unknown) => + operation(tx), + ), + }, + }; +}); + +vi.mock("@/db", () => ({ db: mocks.db })); +vi.mock("@/lib/public-api", () => ({ + resolvePersistedSourceFromRows: vi.fn(), +})); + +import { + cloneGitHubBuildServiceRevision, + createGitHubBuildServiceRevision, + createRolloutWithServiceRevision, +} from "@/lib/service-revisions"; + +function sourceSpecification(): ServiceRevisionSpec { + return { + schemaVersion: 2, + image: "registry.test/project-1/service-1:revision-original", + source: { + type: "github", + repository: "https://github.com/acme/app", + repositoryId: 101, + branch: "main", + commitSha: "0123456789abcdef0123456789abcdef01234567", + rootDir: "apps/web", + authentication: { type: "github_app", installationId: 123 }, + }, + hostname: "service-1", + stateful: false, + serverless: { + enabled: false, + sleepAfterSeconds: 300, + wakeTimeoutSeconds: 300, + }, + healthCheck: null, + startCommand: "node server.js", + resourceLimits: { cpuCores: 1, memoryMb: 512 }, + placements: [{ serverId: "server-1", count: 1 }], + ports: [], + secrets: [ + { + key: "TOKEN", + encryptedValue: "ciphertext", + updatedAt: "2026-07-21T00:00:00.000Z", + }, + ], + volumes: [], + }; +} + +describe("GitHub build service revisions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.selectResults.length = 0; + mocks.insertedValues.length = 0; + mocks.returningResults.length = 0; + }); + + it("clones immutable source and config while reserving a new artifact", async () => { + const original = sourceSpecification(); + mocks.selectResults.push( + [ + { + id: "revision-original", + serviceId: "service-1", + specification: original, + }, + ], + [{ id: "service-1" }], + ); + mocks.returningResults.push([{ id: "revision-retry" }]); + + await cloneGitHubBuildServiceRevision({ + serviceId: "service-1", + sourceRevisionId: "revision-original", + actor: { type: "system" }, + }); + + const inserted = mocks.insertedValues[0] as { + id: string; + specification: ServiceRevisionSpec; + }; + expect(inserted.id).not.toBe("revision-original"); + expect(inserted.specification.image).toBe( + `registry.test/project-1/service-1:revision-${inserted.id}`, + ); + expect(inserted.specification.source).toEqual(original.source); + expect({ + ...inserted.specification, + image: original.image, + }).toEqual(original); + }); + + it("rejects reuse of a deterministic revision id for another commit", async () => { + mocks.selectResults.push([ + { + id: "revision-deterministic", + serviceId: "service-1", + specification: sourceSpecification(), + }, + ]); + + await expect( + createGitHubBuildServiceRevision({ + id: "revision-deterministic", + serviceId: "service-1", + image: + "registry.test/project-1/service-1:revision-revision-deterministic", + commitSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + expectedRepository: "https://github.com/acme/app", + expectedBranch: "main", + actor: { type: "system" }, + }), + ).rejects.toThrow("Service revision idempotency conflict"); + expect(mocks.tx.insert).not.toHaveBeenCalled(); + }); + + it("combines current runtime config with a GitHub base artifact", async () => { + const base = sourceSpecification(); + mocks.selectResults.push( + [{ specification: base }], + [], + [ + { + id: "service-1", + name: "Current service", + image: "registry.test/project-1/service-1:latest", + hostname: "current-hostname", + stateful: false, + serverlessEnabled: false, + serverlessSleepAfterSeconds: 300, + serverlessWakeTimeoutSeconds: 300, + healthCheckCmd: null, + healthCheckInterval: null, + healthCheckTimeout: null, + healthCheckRetries: null, + healthCheckStartPeriod: null, + startCommand: "node current.js", + resourceCpuLimit: 2, + resourceMemoryLimitMb: 1024, + }, + ], + [{ serverId: "server-target", count: 1 }], + [], + [], + [], + ); + mocks.returningResults.push([{ id: "revision-current-config" }], []); + + await createRolloutWithServiceRevision( + "service-1", + { type: "system" }, + "revision-active", + ); + + const inserted = mocks.insertedValues[0] as { + specification: ServiceRevisionSpec; + }; + expect(inserted.specification).toMatchObject({ + image: base.image, + source: base.source, + hostname: "current-hostname", + startCommand: "node current.js", + resourceLimits: { cpuCores: 2, memoryMb: 1024 }, + placements: [{ serverId: "server-target", count: 1 }], + }); + expect(inserted.specification.image).not.toContain(":latest"); + }); +}); diff --git a/web/tests/service-revision-changes.test.ts b/web/tests/service-revision-changes.test.ts index ba956c5c..84dd722c 100644 --- a/web/tests/service-revision-changes.test.ts +++ b/web/tests/service-revision-changes.test.ts @@ -4,8 +4,9 @@ import type { ServiceRevisionSpec } from "@/lib/service-revision-spec"; function spec(): ServiceRevisionSpec { return { - schemaVersion: 1, + schemaVersion: 2, image: "app:v1", + source: { type: "image", image: "app:v1" }, hostname: "app", stateful: false, serverless: { diff --git a/web/tests/service-revision-spec.test.ts b/web/tests/service-revision-spec.test.ts index 24bc3624..4fd3add4 100644 --- a/web/tests/service-revision-spec.test.ts +++ b/web/tests/service-revision-spec.test.ts @@ -104,4 +104,75 @@ describe("service revision specification", () => { "At least one replica is required", ); }); + + it("snapshots GitHub source provenance and the reserved runtime image", () => { + const spec = buildServiceRevisionSpec(draft(), { + image: "registry.test/project/service:revision-1", + source: { + type: "github", + repository: "https://github.com/techulus/cloud", + repositoryId: 123, + branch: "main", + commitSha: "0123456789abcdef0123456789abcdef01234567", + rootDir: "web", + authentication: { type: "github_app", installationId: 456 }, + }, + }); + + expect(spec).toMatchObject({ + schemaVersion: 2, + image: "registry.test/project/service:revision-1", + source: { + type: "github", + repository: "https://github.com/techulus/cloud", + branch: "main", + commitSha: "0123456789abcdef0123456789abcdef01234567", + rootDir: "web", + authentication: { type: "github_app", installationId: 456 }, + }, + }); + }); + + it("allows an unrolled build revision to snapshot zero placements", () => { + expect(() => + buildServiceRevisionSpec(draft({ placements: [] }), { + allowNoPlacements: true, + }), + ).not.toThrow(); + }); + + it("rejects serverless revisions without a public HTTP port and domain", () => { + const input = draft({ + ports: [ + { + port: 3000, + isPublic: false, + domain: null, + protocol: "http", + externalPort: null, + tlsPassthrough: false, + }, + ], + }); + input.service.serverlessEnabled = true; + + expect(() => buildServiceRevisionSpec(input)).toThrow( + "Serverless services require a public HTTP port with a domain", + ); + }); + + it("accepts serverless revisions with at least one public HTTP port and domain", () => { + const input = draft(); + input.service.serverlessEnabled = true; + input.ports[0] = { + port: 443, + isPublic: true, + domain: "api.example.com", + protocol: "http", + externalPort: null, + tlsPassthrough: false, + }; + + expect(() => buildServiceRevisionSpec(input)).not.toThrow(); + }); }); diff --git a/web/tests/service-revisions-route.test.ts b/web/tests/service-revisions-route.test.ts index e6f598ec..43f5955e 100644 --- a/web/tests/service-revisions-route.test.ts +++ b/web/tests/service-revisions-route.test.ts @@ -40,8 +40,9 @@ function revisionSpec( encryptedValue = "cipher", ): ServiceRevisionSpec { return { - schemaVersion: 1, + schemaVersion: 2, image, + source: { type: "image", image }, hostname: "app", stateful: false, serverless: { diff --git a/web/tests/trigger-build.test.ts b/web/tests/trigger-build.test.ts new file mode 100644 index 00000000..bd627261 --- /dev/null +++ b/web/tests/trigger-build.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + rows: [] as unknown[][], + select: vi.fn(), + send: vi.fn(), + resolveGitHubCommit: vi.fn(), + createBuildTrigger: vi.fn((data) => ({ name: "build/trigger", data })), + createGitHubBuildServiceRevision: vi.fn(), + cloneGitHubBuildServiceRevision: vi.fn(), +})); + +vi.mock("@/db", () => ({ + db: { select: mocks.select }, +})); +vi.mock("@/lib/inngest/client", () => ({ + inngest: { send: mocks.send }, +})); +vi.mock("@/lib/github", () => ({ + resolveGitHubCommit: mocks.resolveGitHubCommit, +})); +vi.mock("@/lib/inngest/events", () => ({ + inngestEvents: { + buildTrigger: { create: mocks.createBuildTrigger }, + }, +})); +vi.mock("@/lib/service-revisions", () => ({ + createGitHubBuildServiceRevision: mocks.createGitHubBuildServiceRevision, + cloneGitHubBuildServiceRevision: mocks.cloneGitHubBuildServiceRevision, +})); + +import { + requeueBuildRevisionInternal, + triggerBuildInternal, +} from "@/lib/trigger-build"; + +function queryReturning(rows: unknown[]) { + const query = { + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: (resolve: (value: unknown[]) => unknown) => + Promise.resolve(rows).then(resolve), + }; + query.from.mockReturnValue(query); + query.where.mockReturnValue(query); + query.limit.mockReturnValue(query); + return query; +} + +describe("internal GitHub build trigger", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.REGISTRY_HOST = "registry.test"; + mocks.rows = []; + mocks.createGitHubBuildServiceRevision.mockResolvedValue({}); + mocks.resolveGitHubCommit.mockResolvedValue({ + sha: "0123456789abcdef0123456789abcdef01234567", + message: "Resolved source commit", + author: "octocat", + date: "2026-07-20T00:00:00Z", + }); + mocks.select.mockImplementation(() => + queryReturning(mocks.rows.shift() ?? []), + ); + }); + + it("uses App-backed repository precedence and preserves the actor", async () => { + mocks.rows = [ + [ + { + id: "service-1", + projectId: "project-1", + sourceType: "github", + deletedAt: null, + githubRepoUrl: "https://github.com/stale/fallback", + githubBranch: "stale", + githubRootDir: "apps/web", + }, + ], + [ + { + id: "repo-1", + installationId: 123, + repoFullName: "acme/app", + deployBranch: "production", + defaultBranch: "main", + }, + ], + ]; + const actor = { type: "user" as const, userId: "user-1", name: "Alice" }; + + await expect( + triggerBuildInternal("service-1", "manual", actor), + ).resolves.toEqual( + expect.objectContaining({ buildId: null, status: "queued" }), + ); + const revision = mocks.createGitHubBuildServiceRevision.mock.calls[0][0]; + expect(revision).toEqual( + expect.objectContaining({ + serviceId: "service-1", + image: `registry.test/project-1/service-1:revision-${revision.id}`, + commitSha: "0123456789abcdef0123456789abcdef01234567", + expectedRepository: "https://github.com/acme/app", + expectedBranch: "production", + actor, + }), + ); + expect(mocks.createBuildTrigger).toHaveBeenCalledWith({ + serviceId: "service-1", + serviceRevisionId: revision.id, + buildRequestId: expect.any(String), + trigger: "manual", + commitSha: "0123456789abcdef0123456789abcdef01234567", + commitMessage: "Resolved source commit", + branch: "production", + author: "octocat", + actor, + githubDeploymentId: undefined, + }); + expect(mocks.resolveGitHubCommit).toHaveBeenCalledWith( + "acme/app", + "production", + 123, + ); + expect(mocks.send).toHaveBeenCalledTimes(1); + }); + + it("supports a public URL-backed repository without a GitHub App row", async () => { + mocks.rows = [ + [ + { + id: "service-1", + projectId: "project-1", + sourceType: "github", + deletedAt: null, + githubRepoUrl: "https://github.com/acme/public", + githubBranch: "preview", + githubRootDir: "services/api", + }, + ], + [], + ]; + + await triggerBuildInternal("service-1", "scheduled", { type: "system" }); + + const revision = mocks.createGitHubBuildServiceRevision.mock.calls[0][0]; + expect(revision).toEqual( + expect.objectContaining({ + expectedRepository: "https://github.com/acme/public", + expectedBranch: "preview", + }), + ); + expect(mocks.createBuildTrigger).toHaveBeenCalledWith({ + serviceId: "service-1", + serviceRevisionId: revision.id, + buildRequestId: expect.any(String), + trigger: "scheduled", + commitSha: "0123456789abcdef0123456789abcdef01234567", + commitMessage: "Resolved source commit", + branch: "preview", + author: "octocat", + actor: { type: "system" }, + githubDeploymentId: undefined, + }); + expect(mocks.resolveGitHubCommit).toHaveBeenCalledWith( + "acme/public", + "preview", + undefined, + ); + }); + + it("rejects a non-GitHub service before queueing work", async () => { + mocks.rows = [ + [ + { + id: "service-1", + sourceType: "image", + deletedAt: null, + }, + ], + ]; + + await expect( + triggerBuildInternal("service-1", "manual", { type: "system" }), + ).rejects.toThrow("Active GitHub service not found"); + expect(mocks.send).not.toHaveBeenCalled(); + expect(mocks.createGitHubBuildServiceRevision).not.toHaveBeenCalled(); + }); + + it("retries from a cloned revision with a new artifact identity", async () => { + const retrySpecification = { + schemaVersion: 2, + image: "registry.test/project-1/service-1:revision-retry-1", + source: { + type: "github", + repository: "https://github.com/acme/app", + repositoryId: 101, + branch: "main", + commitSha: "0123456789abcdef0123456789abcdef01234567", + rootDir: "apps/web", + authentication: { type: "github_app", installationId: 123 }, + }, + hostname: "service-1", + stateful: false, + serverless: { + enabled: false, + sleepAfterSeconds: 300, + wakeTimeoutSeconds: 300, + }, + healthCheck: null, + startCommand: null, + resourceLimits: { cpuCores: null, memoryMb: null }, + placements: [{ serverId: "server-1", count: 1 }], + ports: [], + secrets: [], + volumes: [], + }; + mocks.cloneGitHubBuildServiceRevision.mockResolvedValue({ + id: "retry-1", + specification: retrySpecification, + }); + const actor = { type: "user" as const, userId: "user-1", name: "Alice" }; + + await expect( + requeueBuildRevisionInternal({ + serviceId: "service-1", + serviceRevisionId: "failed-revision", + commitMessage: "Retry immutable source", + actor, + }), + ).resolves.toEqual({ + status: "queued", + serviceRevisionId: "retry-1", + buildRequestId: expect.any(String), + }); + expect(mocks.cloneGitHubBuildServiceRevision).toHaveBeenCalledWith({ + serviceId: "service-1", + sourceRevisionId: "failed-revision", + actor, + }); + expect(mocks.createBuildTrigger).toHaveBeenCalledWith( + expect.objectContaining({ + serviceId: "service-1", + serviceRevisionId: "retry-1", + commitSha: retrySpecification.source.commitSha, + branch: "main", + }), + ); + }); +}); diff --git a/web/tests/victoria-logs.test.ts b/web/tests/victoria-logs.test.ts index 2592ccf2..d1c40861 100644 --- a/web/tests/victoria-logs.test.ts +++ b/web/tests/victoria-logs.test.ts @@ -148,6 +148,140 @@ describe("VictoriaLogs queries", () => { ); }); + it("returns an initial public tail in chronological tuple order", async () => { + const { queryPublicServiceLogs } = await loadVictoriaLogs(); + const urls: URL[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + urls.push(new URL(String(input))); + return jsonLinesResponse([ + storedLogWithId("2026-07-10T00:00:00Z", "extra", eventId("a")), + storedLogWithId("2026-07-10T00:00:01Z", "first", eventId("b")), + storedLogWithId("2026-07-10T00:00:02Z", "second", eventId("c")), + ]); + }), + ); + + const result = await queryPublicServiceLogs({ + serviceId: "service-1", + limit: 2, + logType: "container", + }); + + expect(result.logs.map((log) => log._msg)).toEqual(["first", "second"]); + expect(result.hasMore).toBe(false); + const url = urls[0]; + expect(url?.searchParams.has("limit")).toBe(false); + expect(url?.searchParams.get("timeout")).toBe("4s"); + expect(url?.searchParams.get("query")).toContain( + "| first 3 by (_time desc, event_id desc) | sort by (_time, event_id)", + ); + expect(url?.searchParams.get("query")).toContain("_time:24h"); + }); + + it("pages equal-timestamp public logs by event ID without skips", async () => { + const { queryPublicServiceLogs } = await loadVictoriaLogs(); + const queries: string[] = []; + const logs = [ + storedLogWithId("2026-07-10T00:00:00Z", "a", eventId("a")), + storedLogWithId("2026-07-10T00:00:00Z", "b", eventId("b")), + storedLogWithId("2026-07-10T00:00:00Z", "c", eventId("c")), + ]; + const fetchMock = vi + .fn() + .mockImplementationOnce(async (input: string | URL | Request) => { + queries.push(new URL(String(input)).searchParams.get("query") || ""); + return jsonLinesResponse(logs); + }) + .mockImplementationOnce(async (input: string | URL | Request) => { + queries.push(new URL(String(input)).searchParams.get("query") || ""); + return jsonLinesResponse([logs[2]]); + }); + vi.stubGlobal("fetch", fetchMock); + + const first = await queryPublicServiceLogs({ + serviceId: "service-1", + limit: 2, + logType: "container", + cursor: { time: "2026-07-09T23:59:59Z", eventId: "" }, + }); + const second = await queryPublicServiceLogs({ + serviceId: "service-1", + limit: 2, + logType: "container", + cursor: { + time: first.logs[1]._time, + eventId: first.logs[1].event_id ?? "", + }, + }); + + expect(first.logs.map((log) => log._msg)).toEqual(["a", "b"]); + expect(first.hasMore).toBe(true); + expect(second.logs.map((log) => log._msg)).toEqual(["c"]); + expect(second.hasMore).toBe(false); + expect(queries[0]).toContain("| first 3 by (_time, event_id)"); + expect(queries[0]).toContain("_time:>=2026-07-09T23:59:59Z"); + expect(queries[1]).toContain( + `_time:>=2026-07-10T00:00:00Z _time:<=2026-07-10T00:00:00Z event_id:>"${eventId("b")}"`, + ); + }); + + it("fails loudly rather than skipping unidentified logs while following", async () => { + const { queryPublicServiceLogs, ServiceLogCursorUnavailableError } = + await loadVictoriaLogs(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + jsonLinesResponse([ + storedLog("2026-07-10T00:00:00Z", "legacy agent log"), + ]), + ), + ); + + await expect( + queryPublicServiceLogs({ + serviceId: "service-1", + limit: 2, + logType: "container", + cursor: { + time: "2026-07-09T23:59:59Z", + eventId: eventId("a"), + }, + }), + ).rejects.toBeInstanceOf(ServiceLogCursorUnavailableError); + }); + + it("propagates caller cancellation to the VictoriaLogs request", async () => { + const { queryPublicServiceLogs } = await loadVictoriaLogs(); + const controller = new AbortController(); + let providerSignal: AbortSignal | undefined; + vi.stubGlobal( + "fetch", + vi.fn((_input: string | URL | Request, init?: RequestInit) => { + providerSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + providerSignal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }); + }), + ); + + const result = queryPublicServiceLogs({ + serviceId: "service-1", + limit: 2, + logType: "container", + signal: controller.signal, + }); + controller.abort(); + + await expect(result).rejects.toMatchObject({ name: "AbortError" }); + expect(providerSignal?.aborted).toBe(true); + }); + it("queries deployment logs after the supplied cursor", async () => { const { queryLogsByDeployment } = await loadVictoriaLogs(); let query = ""; @@ -237,6 +371,14 @@ function storedLog(time: string, message: string) { }; } +function eventId(suffix: string) { + return `e${"1".padStart(19, "0")}${suffix.repeat(26)}`; +} + +function storedLogWithId(time: string, message: string, id: string) { + return { ...storedLog(time, message), event_id: id }; +} + function jsonLinesResponse(logs: unknown[]) { return new Response(logs.map((log) => JSON.stringify(log)).join("\n"), { status: 200,