From 25b1c1ed156fc92d8c80d0a0aa3367b050dd5aa6 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 07:49:58 +0000 Subject: [PATCH 1/8] fix: use monospace font for command output Amp-Thread-ID: https://ampcode.com/threads/T-019fd59b-b4bd-736d-b6c6-19a0f120580c Co-authored-by: Arjun Komath --- .../[slug]/[env]/services/[serviceId]/commands/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/commands/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/commands/page.tsx index e8e7aeeb..ca65c9b2 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/commands/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/commands/page.tsx @@ -326,7 +326,7 @@ export default function CommandsPage() { Combined output -
+										
 											{item.output}
 											{item.errorMessage
 												? `${item.output ? "\n" : ""}${item.errorMessage}`

From 31598283bc503c348084ceb6abc36133c9c36553 Mon Sep 17 00:00:00 2001
From: Amp 
Date: Thu, 6 Aug 2026 07:58:06 +0000
Subject: [PATCH 2/8] Verify control-plane release artifacts

Amp-Thread-ID: https://ampcode.com/threads/T-019fd59c-389b-7371-a65f-cf0c68fb7ebf
Co-authored-by: Arjun Komath 
---
 .github/workflows/release.yml           |  81 ++++++-
 deployment/compose.postgres.yml         |   8 +-
 deployment/compose.production.yml       |   8 +-
 deployment/updater/main.go              | 283 +++++++++++++++++++++---
 deployment/updater/main_test.go         | 240 ++++++++++++++++++++
 web/components/core/upgrade-overlay.tsx |  31 ++-
 6 files changed, 604 insertions(+), 47 deletions(-)
 create mode 100644 deployment/updater/main_test.go

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 186feddc..2b22c9f3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -118,6 +118,9 @@ jobs:
     needs: [agent, cli, containers]
     runs-on: blacksmith-2vcpu-ubuntu-2404
     steps:
+      - name: Checkout
+        uses: actions/checkout@v6
+
       - name: Download agent artifacts
         uses: actions/download-artifact@v8
         with:
@@ -132,11 +135,71 @@ jobs:
           pattern: tc-*
           merge-multiple: true
 
+      - name: Download image digests
+        uses: actions/download-artifact@v8
+        with:
+          path: image-digests
+          pattern: release-digest-*
+          merge-multiple: true
+
       - name: Generate SHA256 checksums
         run: |
           cd binaries
           sha256sum agent-* tc-* tc_* > checksums.txt
 
+      - name: Generate release manifest
+        env:
+          RELEASE_VERSION: ${{ github.ref_name }}
+          RELEASE_COMMIT: ${{ github.sha }}
+        run: |
+          sha256_pattern='^[0-9a-f]{64}$'
+          digest_pattern='^sha256:[0-9a-f]{64}$'
+
+          agent_amd64=$(sha256sum binaries/agent-linux-amd64 | awk '{print $1}')
+          agent_arm64=$(sha256sum binaries/agent-linux-arm64 | awk '{print $1}')
+          compose_production=$(sha256sum deployment/compose.production.yml | awk '{print $1}')
+          compose_postgres=$(sha256sum deployment/compose.postgres.yml | awk '{print $1}')
+          web_digest=$(cat image-digests/web)
+          registry_digest=$(cat image-digests/registry)
+          updater_digest=$(cat image-digests/updater)
+
+          for checksum in "$agent_amd64" "$agent_arm64" "$compose_production" "$compose_postgres"; do
+            [[ "$checksum" =~ $sha256_pattern ]]
+          done
+          for digest in "$web_digest" "$registry_digest" "$updater_digest"; do
+            [[ "$digest" =~ $digest_pattern ]]
+          done
+          [[ "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]
+          [[ "$RELEASE_COMMIT" =~ ^[0-9a-f]{40}$ ]]
+
+          jq -n \
+            --arg version "$RELEASE_VERSION" \
+            --arg commit "$RELEASE_COMMIT" \
+            --arg agent_amd64 "$agent_amd64" \
+            --arg agent_arm64 "$agent_arm64" \
+            --arg compose_production "$compose_production" \
+            --arg compose_postgres "$compose_postgres" \
+            --arg web_digest "$web_digest" \
+            --arg registry_digest "$registry_digest" \
+            --arg updater_digest "$updater_digest" \
+            '{
+              version: $version,
+              commit: $commit,
+              binaries: {
+                "agent-linux-amd64": $agent_amd64,
+                "agent-linux-arm64": $agent_arm64
+              },
+              composeFiles: {
+                "deployment/compose.production.yml": $compose_production,
+                "deployment/compose.postgres.yml": $compose_postgres
+              },
+              images: {
+                web: $web_digest,
+                registry: $registry_digest,
+                updater: $updater_digest
+              }
+            }' > binaries/release-manifest.json
+
       - name: Create GitHub release
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -145,7 +208,7 @@ jobs:
             --repo ${{ github.repository }} \
             --title "${{ github.ref_name }}" \
             --generate-notes \
-            binaries/agent-* binaries/tc-* binaries/tc_* binaries/checksums.txt
+            binaries/agent-* binaries/tc-* binaries/tc_* binaries/checksums.txt binaries/release-manifest.json
 
   homebrew:
     needs: release
@@ -359,3 +422,19 @@ jobs:
             --tag "$image:${{ github.ref_name }}" \
             --tag "$image:tip" \
             $(printf "$image@sha256:%s " *)
+
+      - name: Export multi-platform manifest digest
+        run: |
+          image="ghcr.io/${{ github.repository }}/${{ matrix.image }}:${{ github.ref_name }}"
+          mkdir -p /tmp/release-digests
+          docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' \
+            | jq -er '.digest | select(test("^sha256:[0-9a-f]{64}$"))' \
+            > "/tmp/release-digests/${{ matrix.image }}"
+
+      - name: Upload multi-platform manifest digest
+        uses: actions/upload-artifact@v7
+        with:
+          name: release-digest-${{ matrix.image }}
+          path: /tmp/release-digests/${{ matrix.image }}
+          if-no-files-found: error
+          retention-days: 1
diff --git a/deployment/compose.postgres.yml b/deployment/compose.postgres.yml
index 705ee6f1..e92ccbda 100644
--- a/deployment/compose.postgres.yml
+++ b/deployment/compose.postgres.yml
@@ -70,7 +70,7 @@ services:
     restart: unless-stopped
 
   migrate:
-    image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}
+    image: ${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:tip}
     env_file:
       - ./.env
     environment:
@@ -96,7 +96,7 @@ services:
     restart: on-failure
 
   web:
-    image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}
+    image: ${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:tip}
     scale: ${WEB_REPLICAS:-1}
     env_file:
       - ./.env
@@ -147,7 +147,7 @@ services:
     restart: unless-stopped
 
   control-plane-updater:
-    image: ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:-tip}
+    image: ${TECHULUS_CLOUD_UPDATER_IMAGE:-ghcr.io/techulus/cloud/updater:tip}
     environment:
       - DEPLOY_DIR=/opt/techulus-cloud
       - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN}
@@ -163,7 +163,7 @@ services:
     restart: unless-stopped
 
   registry:
-    image: ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:-tip}
+    image: ${TECHULUS_CLOUD_REGISTRY_IMAGE:-ghcr.io/techulus/cloud/registry:tip}
     env_file:
       - ./.env
     volumes:
diff --git a/deployment/compose.production.yml b/deployment/compose.production.yml
index 097eb523..cce03c72 100644
--- a/deployment/compose.production.yml
+++ b/deployment/compose.production.yml
@@ -52,7 +52,7 @@ services:
     restart: unless-stopped
 
   migrate:
-    image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}
+    image: ${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:tip}
     env_file:
       - ./.env
     environment:
@@ -75,7 +75,7 @@ services:
     restart: on-failure
 
   web:
-    image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}
+    image: ${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:tip}
     scale: ${WEB_REPLICAS:-1}
     env_file:
       - ./.env
@@ -126,7 +126,7 @@ services:
     restart: unless-stopped
 
   control-plane-updater:
-    image: ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:-tip}
+    image: ${TECHULUS_CLOUD_UPDATER_IMAGE:-ghcr.io/techulus/cloud/updater:tip}
     environment:
       - DEPLOY_DIR=/opt/techulus-cloud
       - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN}
@@ -142,7 +142,7 @@ services:
     restart: unless-stopped
 
   registry:
-    image: ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:-tip}
+    image: ${TECHULUS_CLOUD_REGISTRY_IMAGE:-ghcr.io/techulus/cloud/registry:tip}
     env_file:
       - ./.env
     volumes:
diff --git a/deployment/updater/main.go b/deployment/updater/main.go
index ce9d0c2b..e2f567a6 100644
--- a/deployment/updater/main.go
+++ b/deployment/updater/main.go
@@ -3,7 +3,9 @@ package main
 import (
 	"bufio"
 	"bytes"
+	"crypto/sha256"
 	"crypto/subtle"
+	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -28,24 +30,50 @@ type updaterStatus struct {
 	Logs          []string `json:"logs"`
 }
 
+type releaseManifest struct {
+	Version      string            `json:"version"`
+	Commit       string            `json:"commit"`
+	Binaries     map[string]string `json:"binaries"`
+	ComposeFiles map[string]string `json:"composeFiles"`
+	Images       map[string]string `json:"images"`
+}
+
 type server struct {
-	deployDir  string
-	token      string
-	rawBaseURL string
-	healthURL  string
+	deployDir      string
+	token          string
+	rawBaseURL     string
+	releaseBaseURL string
+	healthURL      string
+	httpClient     *http.Client
 
 	mu     sync.Mutex
 	status updaterStatus
 }
 
-var versionPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$`)
+var (
+	versionPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$`)
+	commitPattern  = regexp.MustCompile(`^[0-9a-f]{40}$`)
+	sha256Pattern  = regexp.MustCompile(`^[0-9a-f]{64}$`)
+	digestPattern  = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
+)
+
+var composeManifestPaths = []string{
+	"deployment/compose.production.yml",
+	"deployment/compose.postgres.yml",
+}
+
+var imageNames = []string{"web", "registry", "updater"}
+
+const maxReleaseManifestSize = 1024 * 1024
 
 func main() {
 	s := &server{
-		deployDir:  getenv("DEPLOY_DIR", "/opt/techulus-cloud"),
-		token:      os.Getenv("CONTROL_PLANE_UPDATER_TOKEN"),
-		rawBaseURL: getenv("RAW_BASE_URL", "https://raw.githubusercontent.com/techulus/cloud"),
-		healthURL:  getenv("WEB_HEALTH_URL", "http://web:3000/api/health"),
+		deployDir:      getenv("DEPLOY_DIR", "/opt/techulus-cloud"),
+		token:          os.Getenv("CONTROL_PLANE_UPDATER_TOKEN"),
+		rawBaseURL:     getenv("RAW_BASE_URL", "https://raw.githubusercontent.com/techulus/cloud"),
+		releaseBaseURL: getenv("RELEASE_BASE_URL", "https://github.com/techulus/cloud/releases/download"),
+		healthURL:      getenv("WEB_HEALTH_URL", "http://web:3000/api/health"),
+		httpClient:     &http.Client{Timeout: 2 * time.Minute},
 	}
 	s.status = s.readStatus()
 
@@ -218,20 +246,167 @@ func parseEnv(path string) (map[string]string, string, error) {
 	return env, string(data), nil
 }
 
-func updateEnvVersion(envPath, text, targetVersion string) error {
+type envSetting struct {
+	key   string
+	value string
+}
+
+func updateEnv(envPath, text string, settings []envSetting) error {
 	lines := strings.Split(strings.TrimRight(text, "\r\n"), "\n")
-	found := false
+	found := make(map[string]bool, len(settings))
 	for i, line := range lines {
-		if strings.HasPrefix(line, "TECHULUS_CLOUD_VERSION=") {
-			lines[i] = "TECHULUS_CLOUD_VERSION=" + targetVersion
-			found = true
-			break
+		for _, setting := range settings {
+			if strings.HasPrefix(line, setting.key+"=") {
+				lines[i] = setting.key + "=" + setting.value
+				found[setting.key] = true
+				break
+			}
+		}
+	}
+	for _, setting := range settings {
+		if !found[setting.key] {
+			lines = append(lines, setting.key+"="+setting.value)
+		}
+	}
+	return writeFileAtomically(envPath, []byte(strings.Join(lines, "\n")+"\n"), 0o600)
+}
+
+func manifestEnvSettings(targetVersion string, manifest *releaseManifest) []envSetting {
+	return []envSetting{
+		{key: "TECHULUS_CLOUD_VERSION", value: targetVersion},
+		{key: "TECHULUS_CLOUD_WEB_IMAGE", value: imageReference("web", manifest.Images["web"])},
+		{key: "TECHULUS_CLOUD_REGISTRY_IMAGE", value: imageReference("registry", manifest.Images["registry"])},
+		{key: "TECHULUS_CLOUD_UPDATER_IMAGE", value: imageReference("updater", manifest.Images["updater"])},
+	}
+}
+
+func imageReference(name, digest string) string {
+	return fmt.Sprintf("ghcr.io/techulus/cloud/%s@%s", name, digest)
+}
+
+func validateReleaseManifest(manifest *releaseManifest, targetVersion string) error {
+	if manifest.Version != targetVersion {
+		return fmt.Errorf("release manifest version %q does not match target %q", manifest.Version, targetVersion)
+	}
+	if !commitPattern.MatchString(manifest.Commit) {
+		return errors.New("release manifest contains an invalid commit")
+	}
+	for _, name := range []string{"agent-linux-amd64", "agent-linux-arm64"} {
+		if !sha256Pattern.MatchString(manifest.Binaries[name]) {
+			return fmt.Errorf("release manifest contains an invalid checksum for %s", name)
+		}
+	}
+	for _, path := range composeManifestPaths {
+		if !sha256Pattern.MatchString(manifest.ComposeFiles[path]) {
+			return fmt.Errorf("release manifest contains an invalid checksum for %s", path)
 		}
 	}
-	if !found {
-		lines = append(lines, "TECHULUS_CLOUD_VERSION="+targetVersion)
+	for _, name := range imageNames {
+		if !digestPattern.MatchString(manifest.Images[name]) {
+			return fmt.Errorf("release manifest contains an invalid digest for %s", name)
+		}
+	}
+	return nil
+}
+
+func (s *server) client() *http.Client {
+	if s.httpClient != nil {
+		return s.httpClient
+	}
+	return &http.Client{Timeout: 2 * time.Minute}
+}
+
+func (s *server) fetchReleaseManifest(targetVersion string) (*releaseManifest, error) {
+	url := fmt.Sprintf("%s/%s/release-manifest.json", strings.TrimRight(s.releaseBaseURL, "/"), targetVersion)
+	response, err := s.client().Get(url)
+	if err != nil {
+		return nil, fmt.Errorf("failed to download release manifest: %w", err)
+	}
+	defer response.Body.Close()
+	if response.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("release manifest download failed with status %d", response.StatusCode)
+	}
+
+	body, err := io.ReadAll(io.LimitReader(response.Body, maxReleaseManifestSize+1))
+	if err != nil {
+		return nil, fmt.Errorf("failed to read release manifest: %w", err)
+	}
+	if len(body) > maxReleaseManifestSize {
+		return nil, errors.New("release manifest exceeds maximum size")
+	}
+
+	var manifest releaseManifest
+	decoder := json.NewDecoder(bytes.NewReader(body))
+	decoder.DisallowUnknownFields()
+	if err := decoder.Decode(&manifest); err != nil {
+		return nil, fmt.Errorf("failed to parse release manifest: %w", err)
+	}
+	var trailing any
+	if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
+		return nil, errors.New("release manifest contains trailing data")
+	}
+	if err := validateReleaseManifest(&manifest, targetVersion); err != nil {
+		return nil, err
+	}
+	return &manifest, nil
+}
+
+func (s *server) downloadFile(url, destination string) error {
+	response, err := s.client().Get(url)
+	if err != nil {
+		return fmt.Errorf("failed to download %s: %w", filepath.Base(destination), err)
+	}
+	defer response.Body.Close()
+	if response.StatusCode != http.StatusOK {
+		return fmt.Errorf("download of %s failed with status %d", filepath.Base(destination), response.StatusCode)
+	}
+
+	file, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+	if err != nil {
+		return err
+	}
+	if _, err := io.Copy(file, response.Body); err != nil {
+		file.Close()
+		return err
 	}
-	return os.WriteFile(envPath, []byte(strings.Join(lines, "\n")+"\n"), 0o600)
+	return file.Close()
+}
+
+func verifyFileSHA256(path, expected string) error {
+	file, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+
+	hash := sha256.New()
+	if _, err := io.Copy(hash, file); err != nil {
+		return err
+	}
+	actual := hex.EncodeToString(hash.Sum(nil))
+	if actual != expected {
+		return fmt.Errorf("checksum verification failed for %s", filepath.Base(path))
+	}
+	return nil
+}
+
+func (s *server) stageComposeFiles(manifest *releaseManifest, stagingDir string) error {
+	for _, manifestPath := range composeManifestPaths {
+		name := filepath.Base(manifestPath)
+		destination := filepath.Join(stagingDir, name)
+		url := fmt.Sprintf("%s/%s/%s", strings.TrimRight(s.rawBaseURL, "/"), manifest.Commit, manifestPath)
+		if err := s.downloadFile(url, destination); err != nil {
+			return err
+		}
+		if err := verifyFileSHA256(destination, manifest.ComposeFiles[manifestPath]); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func supportedComposeFile(name string) bool {
+	return name == "compose.production.yml" || name == "compose.postgres.yml"
 }
 
 func (s *server) upgrade(targetVersion string) {
@@ -271,6 +446,21 @@ func (s *server) runUpgrade(targetVersion string, backupDir *string, composeFile
 	if !versionPattern.MatchString(targetVersion) {
 		return errors.New("invalid target version")
 	}
+	manifest, err := s.fetchReleaseManifest(targetVersion)
+	if err != nil {
+		return err
+	}
+
+	stagingDir, err := os.MkdirTemp(s.deployDir, ".update-staging-")
+	if err != nil {
+		return err
+	}
+	defer os.RemoveAll(stagingDir)
+
+	s.logf("downloading and verifying compose files for %s at %s", targetVersion, manifest.Commit)
+	if err := s.stageComposeFiles(manifest, stagingDir); err != nil {
+		return err
+	}
 
 	envPath := filepath.Join(s.deployDir, ".env")
 	env, envText, err := parseEnv(envPath)
@@ -281,6 +471,9 @@ func (s *server) runUpgrade(targetVersion string, backupDir *string, composeFile
 	if value := env["COMPOSE_FILE"]; value != "" {
 		*composeFile = value
 	}
+	if !supportedComposeFile(*composeFile) {
+		return fmt.Errorf("unsupported COMPOSE_FILE %q", *composeFile)
+	}
 	*backupDir = filepath.Join(s.deployDir, "backups", "update-"+strings.NewReplacer(":", "-", ".", "-").Replace(time.Now().UTC().Format(time.RFC3339Nano)))
 	if err := os.MkdirAll(*backupDir, 0o700); err != nil {
 		return err
@@ -302,14 +495,13 @@ func (s *server) runUpgrade(targetVersion string, backupDir *string, composeFile
 		return err
 	}
 
-	s.logf("downloading compose files for %s", targetVersion)
-	if err := s.run("curl", []string{"-fsSL", fmt.Sprintf("%s/%s/deployment/compose.production.yml", s.rawBaseURL, targetVersion), "-o", "compose.production.yml"}, nil); err != nil {
-		return err
-	}
-	if err := s.run("curl", []string{"-fsSL", fmt.Sprintf("%s/%s/deployment/compose.postgres.yml", s.rawBaseURL, targetVersion), "-o", "compose.postgres.yml"}, nil); err != nil {
-		return err
+	for _, manifestPath := range composeManifestPaths {
+		name := filepath.Base(manifestPath)
+		if err := os.Rename(filepath.Join(stagingDir, name), filepath.Join(s.deployDir, name)); err != nil {
+			return err
+		}
 	}
-	if err := updateEnvVersion(envPath, envText, targetVersion); err != nil {
+	if err := updateEnv(envPath, envText, manifestEnvSettings(targetVersion, manifest)); err != nil {
 		return err
 	}
 
@@ -341,14 +533,47 @@ func copyFile(source, destination string) error {
 	}
 	defer input.Close()
 
-	output, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+	output, err := os.CreateTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".tmp-")
 	if err != nil {
 		return err
 	}
-	defer output.Close()
+	temporaryPath := output.Name()
+	defer os.Remove(temporaryPath)
 
-	_, err = io.Copy(output, input)
-	return err
+	if err := output.Chmod(0o600); err != nil {
+		output.Close()
+		return err
+	}
+	if _, err := io.Copy(output, input); err != nil {
+		output.Close()
+		return err
+	}
+	if err := output.Close(); err != nil {
+		return err
+	}
+	return os.Rename(temporaryPath, destination)
+}
+
+func writeFileAtomically(path string, data []byte, mode os.FileMode) error {
+	file, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-")
+	if err != nil {
+		return err
+	}
+	temporaryPath := file.Name()
+	defer os.Remove(temporaryPath)
+
+	if err := file.Chmod(mode); err != nil {
+		file.Close()
+		return err
+	}
+	if _, err := file.Write(data); err != nil {
+		file.Close()
+		return err
+	}
+	if err := file.Close(); err != nil {
+		return err
+	}
+	return os.Rename(temporaryPath, path)
 }
 
 func (s *server) backupDatabase(env map[string]string, backupDir string) error {
diff --git a/deployment/updater/main_test.go b/deployment/updater/main_test.go
new file mode 100644
index 00000000..b5e653b1
--- /dev/null
+++ b/deployment/updater/main_test.go
@@ -0,0 +1,240 @@
+package main
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+const (
+	testVersion = "v1.2.3"
+	testCommit  = "0123456789abcdef0123456789abcdef01234567"
+)
+
+func validManifest() releaseManifest {
+	checksum := strings.Repeat("a", 64)
+	digest := "sha256:" + strings.Repeat("b", 64)
+	return releaseManifest{
+		Version: testVersion,
+		Commit:  testCommit,
+		Binaries: map[string]string{
+			"agent-linux-amd64": checksum,
+			"agent-linux-arm64": checksum,
+		},
+		ComposeFiles: map[string]string{
+			"deployment/compose.production.yml": checksum,
+			"deployment/compose.postgres.yml":   checksum,
+		},
+		Images: map[string]string{
+			"web":      digest,
+			"registry": digest,
+			"updater":  digest,
+		},
+	}
+}
+
+func TestFetchReleaseManifest(t *testing.T) {
+	tests := []struct {
+		name       string
+		statusCode int
+		mutate     func(*releaseManifest)
+		wantError  string
+	}{
+		{name: "valid", statusCode: http.StatusOK},
+		{name: "missing", statusCode: http.StatusNotFound, wantError: "status 404"},
+		{
+			name:       "version mismatch",
+			statusCode: http.StatusOK,
+			mutate: func(manifest *releaseManifest) {
+				manifest.Version = "v1.2.4"
+			},
+			wantError: "does not match target",
+		},
+		{
+			name:       "invalid image digest",
+			statusCode: http.StatusOK,
+			mutate: func(manifest *releaseManifest) {
+				manifest.Images["web"] = "sha256:invalid"
+			},
+			wantError: "invalid digest for web",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			manifest := validManifest()
+			if test.mutate != nil {
+				test.mutate(&manifest)
+			}
+			handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				if r.URL.Path != "/v1.2.3/release-manifest.json" {
+					t.Fatalf("unexpected manifest path %q", r.URL.Path)
+				}
+				w.WriteHeader(test.statusCode)
+				if test.statusCode == http.StatusOK {
+					if err := json.NewEncoder(w).Encode(manifest); err != nil {
+						t.Fatal(err)
+					}
+				}
+			})
+			httpServer := httptest.NewServer(handler)
+			defer httpServer.Close()
+
+			s := &server{releaseBaseURL: httpServer.URL, httpClient: httpServer.Client()}
+			got, err := s.fetchReleaseManifest(testVersion)
+			if test.wantError != "" {
+				if err == nil || !strings.Contains(err.Error(), test.wantError) {
+					t.Fatalf("expected error containing %q, got %v", test.wantError, err)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatal(err)
+			}
+			if got.Commit != testCommit {
+				t.Fatalf("expected commit %q, got %q", testCommit, got.Commit)
+			}
+		})
+	}
+}
+
+func TestFetchReleaseManifestRejectsMalformedJSON(t *testing.T) {
+	httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		_, _ = w.Write([]byte(`{"version":`))
+	}))
+	defer httpServer.Close()
+
+	s := &server{releaseBaseURL: httpServer.URL, httpClient: httpServer.Client()}
+	_, err := s.fetchReleaseManifest(testVersion)
+	if err == nil || !strings.Contains(err.Error(), "failed to parse release manifest") {
+		t.Fatalf("expected parse error, got %v", err)
+	}
+}
+
+func TestFetchReleaseManifestRejectsTrailingAndOversizedData(t *testing.T) {
+	manifest := validManifest()
+	validJSON, err := json.Marshal(manifest)
+	if err != nil {
+		t.Fatal(err)
+	}
+	tests := []struct {
+		name      string
+		body      []byte
+		wantError string
+	}{
+		{name: "trailing data", body: append(validJSON, []byte(` {}`)...), wantError: "trailing data"},
+		{name: "oversized", body: make([]byte, maxReleaseManifestSize+1), wantError: "exceeds maximum size"},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+				_, _ = w.Write(test.body)
+			}))
+			defer httpServer.Close()
+
+			s := &server{releaseBaseURL: httpServer.URL, httpClient: httpServer.Client()}
+			_, err := s.fetchReleaseManifest(testVersion)
+			if err == nil || !strings.Contains(err.Error(), test.wantError) {
+				t.Fatalf("expected error containing %q, got %v", test.wantError, err)
+			}
+		})
+	}
+}
+
+func TestStageComposeFilesVerifiesChecksumsAndUsesCommit(t *testing.T) {
+	files := map[string]string{
+		"deployment/compose.production.yml": "services:\n  web: {}\n",
+		"deployment/compose.postgres.yml":   "services:\n  postgres: {}\n",
+	}
+	manifest := validManifest()
+	for path, content := range files {
+		manifest.ComposeFiles[path] = checksum(content)
+	}
+
+	httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		prefix := "/" + testCommit + "/"
+		if !strings.HasPrefix(r.URL.Path, prefix) {
+			t.Fatalf("compose request was not commit-pinned: %q", r.URL.Path)
+		}
+		content, ok := files[strings.TrimPrefix(r.URL.Path, prefix)]
+		if !ok {
+			http.NotFound(w, r)
+			return
+		}
+		_, _ = w.Write([]byte(content))
+	}))
+	defer httpServer.Close()
+
+	stagingDir := t.TempDir()
+	s := &server{rawBaseURL: httpServer.URL, httpClient: httpServer.Client()}
+	if err := s.stageComposeFiles(&manifest, stagingDir); err != nil {
+		t.Fatal(err)
+	}
+	for path, content := range files {
+		data, err := os.ReadFile(filepath.Join(stagingDir, filepath.Base(path)))
+		if err != nil {
+			t.Fatal(err)
+		}
+		if string(data) != content {
+			t.Fatalf("unexpected staged content for %s", path)
+		}
+	}
+
+	manifest.ComposeFiles["deployment/compose.postgres.yml"] = strings.Repeat("0", 64)
+	err := s.stageComposeFiles(&manifest, t.TempDir())
+	if err == nil || !strings.Contains(err.Error(), "checksum verification failed") {
+		t.Fatalf("expected checksum mismatch, got %v", err)
+	}
+}
+
+func TestUpdateEnvWritesManifestImageReferences(t *testing.T) {
+	manifest := validManifest()
+	envPath := filepath.Join(t.TempDir(), ".env")
+	input := "ROOT_DOMAIN=cloud.example.com\nTECHULUS_CLOUD_VERSION=v1.0.0\nTECHULUS_CLOUD_WEB_IMAGE=old\n"
+	if err := updateEnv(envPath, input, manifestEnvSettings(testVersion, &manifest)); err != nil {
+		t.Fatal(err)
+	}
+
+	data, err := os.ReadFile(envPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	text := string(data)
+	expected := []string{
+		"ROOT_DOMAIN=cloud.example.com",
+		"TECHULUS_CLOUD_VERSION=" + testVersion,
+		"TECHULUS_CLOUD_WEB_IMAGE=ghcr.io/techulus/cloud/web@" + manifest.Images["web"],
+		"TECHULUS_CLOUD_REGISTRY_IMAGE=ghcr.io/techulus/cloud/registry@" + manifest.Images["registry"],
+		"TECHULUS_CLOUD_UPDATER_IMAGE=ghcr.io/techulus/cloud/updater@" + manifest.Images["updater"],
+	}
+	for _, line := range expected {
+		if !strings.Contains(text, line+"\n") {
+			t.Errorf("updated env missing %q:\n%s", line, text)
+		}
+	}
+}
+
+func TestSupportedComposeFile(t *testing.T) {
+	for _, name := range []string{"compose.production.yml", "compose.postgres.yml"} {
+		if !supportedComposeFile(name) {
+			t.Errorf("expected %q to be supported", name)
+		}
+	}
+	for _, name := range []string{"compose.custom.yml", "/tmp/compose.production.yml", "compose.production.yml:other.yml"} {
+		if supportedComposeFile(name) {
+			t.Errorf("expected %q to be rejected", name)
+		}
+	}
+}
+
+func checksum(content string) string {
+	hash := sha256.Sum256([]byte(content))
+	return hex.EncodeToString(hash[:])
+}
diff --git a/web/components/core/upgrade-overlay.tsx b/web/components/core/upgrade-overlay.tsx
index 206f94b2..e7f3a73d 100644
--- a/web/components/core/upgrade-overlay.tsx
+++ b/web/components/core/upgrade-overlay.tsx
@@ -1,5 +1,6 @@
 "use client";
 
+import { RefreshCw } from "lucide-react";
 import { useEffect, useState } from "react";
 import { toast } from "sonner";
 import type { ControlPlaneUpgradeState } from "@/lib/control-plane-updates";
@@ -56,20 +57,32 @@ export function ControlPlaneUpgradeOverlay({
 
 	return (
 		
-
-
- - +
+
+
+ + + + +
+

Updating {initialState?.targetVersion ? ` to ${initialState.targetVersion}` : ""} - +

+

+ The dashboard will reload automatically when the update completes. + Actions are disabled until then. +

+
+
+
-

- The dashboard will reload automatically when the update completes. - Actions are disabled until then. -

); From c45bf7f8930b3368370777c431089a715eb33023 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 08:27:12 +0000 Subject: [PATCH 3/8] Add manifest-managed service crons Amp-Thread-ID: https://ampcode.com/threads/T-019fd56a-3372-7029-89df-e069c0fe68e4 Co-authored-by: Arjun Komath --- cli/internal/cli/app.go | 11 +- cli/internal/cli/app_test.go | 34 +- cli/internal/cli/types.go | 1 + cli/internal/manifest/manifest.go | 46 +++ cli/internal/manifest/manifest_test.go | 23 +- .../[serviceId]/configuration/page.tsx | 3 + web/app/api/inngest/route.ts | 4 + web/app/api/projects/[id]/services/route.ts | 16 + web/app/api/services/[id]/logs/route.ts | 4 +- .../service/details/crons-section.tsx | 124 ++++++ web/db/schema.ts | 35 ++ web/db/types.ts | 3 + web/lib/inngest/events/index.ts | 6 +- web/lib/inngest/events/service-cron.ts | 5 + web/lib/inngest/functions/crons.ts | 63 +++ web/lib/inngest/functions/index.ts | 2 + .../functions/service-cron-workflow.ts | 20 + web/lib/public-api-routes.ts | 1 + web/lib/public-api.ts | 154 +++++++- web/lib/service-crons.ts | 362 ++++++++++++++++++ web/lib/victoria-logs.ts | 42 +- web/tests/inngest-route.test.ts | 2 + web/tests/log-routes.test.ts | 12 + web/tests/public-api-configuration.test.ts | 1 + web/tests/public-api-plan.test.ts | 74 ++++ web/tests/public-api-source.test.ts | 51 +++ web/tests/service-crons.test.ts | 213 +++++++++++ web/tests/victoria-logs.test.ts | 60 +++ 28 files changed, 1356 insertions(+), 16 deletions(-) create mode 100644 web/components/service/details/crons-section.tsx create mode 100644 web/lib/inngest/events/service-cron.ts create mode 100644 web/lib/inngest/functions/service-cron-workflow.ts create mode 100644 web/lib/service-crons.ts create mode 100644 web/tests/service-crons.test.ts diff --git a/cli/internal/cli/app.go b/cli/internal/cli/app.go index 990de668..5660d86e 100644 --- a/cli/internal/cli/app.go +++ b/cli/internal/cli/app.go @@ -436,6 +436,7 @@ func (a *App) linkCommand() *cobra.Command { HealthCheck *manifest.HealthCheck `json:"healthCheck"` StartCommand *string `json:"startCommand"` Resources *manifest.Resources `json:"resources"` + Crons []manifest.Cron `json:"crons"` } `json:"current"` Management *serviceManagement `json:"management"` } @@ -467,7 +468,7 @@ func (a *App) linkCommand() *cobra.Command { } } } - m := manifest.Manifest{APIVersion: "v1", Target: &manifest.Target{ServiceID: service.ID}, Service: manifest.Service{Name: service.Name, Source: service.Source, Hostname: cfg.Current.Hostname, Ports: ports, Replicas: cfg.Current.Replicas, Placement: placement, HealthCheck: cfg.Current.HealthCheck, StartCommand: cfg.Current.StartCommand, Resources: cfg.Current.Resources}} + m := manifest.Manifest{APIVersion: "v1", Target: &manifest.Target{ServiceID: service.ID}, Service: manifest.Service{Name: service.Name, Source: service.Source, Hostname: cfg.Current.Hostname, Ports: ports, Replicas: cfg.Current.Replicas, Placement: placement, HealthCheck: cfg.Current.HealthCheck, StartCommand: cfg.Current.StartCommand, Resources: cfg.Current.Resources, Crons: cfg.Current.Crons}} if existing != nil { if existing.Manifest.Linked() && existing.Manifest.Target.ServiceID != service.ID { return fmt.Errorf("manifest is linked to service %s; remove target.serviceId before relinking", existing.Manifest.Target.ServiceID) @@ -516,7 +517,11 @@ func (a *App) applyCommand() *cobra.Command { if placement == nil { return errors.New("service.placement is required") } - body := map[string]any{"name": loaded.Manifest.Service.Name, "source": sourcePatch(loaded.Manifest.Service.Source), "hostname": loaded.Manifest.Service.Hostname, "ports": loaded.Manifest.Service.Ports, "healthCheck": loaded.Manifest.Service.HealthCheck, "startCommand": loaded.Manifest.Service.StartCommand, "resources": loaded.Manifest.Service.Resources} + crons := loaded.Manifest.Service.Crons + if crons == nil { + crons = []manifest.Cron{} + } + body := map[string]any{"name": loaded.Manifest.Service.Name, "source": sourcePatch(loaded.Manifest.Service.Source), "hostname": loaded.Manifest.Service.Hostname, "ports": loaded.Manifest.Service.Ports, "healthCheck": loaded.Manifest.Service.HealthCheck, "startCommand": loaded.Manifest.Service.StartCommand, "resources": loaded.Manifest.Service.Resources, "crons": crons} if placement.Mode == "automatic" { body["placement"] = map[string]any{"mode": "automatic", "replicas": loaded.Manifest.Service.Replicas} } else { @@ -636,7 +641,7 @@ func (a *App) finishLink(path string, existing *manifest.Loaded, service service } return a.printLinked(path, target) } - m := manifest.Manifest{APIVersion: "v1", Target: &manifest.Target{ServiceID: serviceID}, Service: manifest.Service{Name: service.Name, Source: service.Source, Hostname: service.Hostname, Ports: service.Ports, Replicas: service.Replicas, Placement: service.Placement, HealthCheck: service.HealthCheck, StartCommand: service.StartCommand, Resources: service.Resources}} + m := manifest.Manifest{APIVersion: "v1", Target: &manifest.Target{ServiceID: serviceID}, Service: manifest.Service{Name: service.Name, Source: service.Source, Hostname: service.Hostname, Ports: service.Ports, Replicas: service.Replicas, Placement: service.Placement, HealthCheck: service.HealthCheck, StartCommand: service.StartCommand, Resources: service.Resources, Crons: service.Crons}} if err := manifest.Save(path, m); err != nil { return err } diff --git a/cli/internal/cli/app_test.go b/cli/internal/cli/app_test.go index 81cd36d2..84cde7cf 100644 --- a/cli/internal/cli/app_test.go +++ b/cli/internal/cli/app_test.go @@ -335,9 +335,12 @@ func TestApplyExactNestedPatchForSources(t *testing.T) { } source := body["source"].(map[string]any) placement := body["placement"].(map[string]any) - if source["type"] != tc.sourceType || placement["mode"] != "automatic" || placement["replicas"] != float64(2) || len(body) != 8 { + if source["type"] != tc.sourceType || placement["mode"] != "automatic" || placement["replicas"] != float64(2) || len(body) != 9 { t.Fatalf("body=%#v", body) } + if crons, ok := body["crons"].([]any); !ok || len(crons) != 0 { + t.Fatalf("omitted crons must be sent as an empty replacement: %#v", body["crons"]) + } if tc.name == "github_clear_root" { rootDir, present := source["rootDir"] if !present || rootDir != nil { @@ -348,6 +351,35 @@ func TestApplyExactNestedPatchForSources(t *testing.T) { } } +func TestLinkAndApplyRoundTripCrons(t *testing.T) { + d := t.TempDir() + var applied map[string]any + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Write([]byte(`{"target":{"project":{"slug":"app"},"environment":{"name":"prod"},"service":{"id":"s","name":"web"}},"service":{"id":"s","name":"web","source":{"type":"image","image":"nginx"},"ports":[],"replicas":1,"placement":{"mode":"automatic"},"hostname":"web","healthCheck":null,"startCommand":null,"resources":null,"crons":[{"path":"/jobs/nightly","schedule":"0 5 * * *"}]},"management":{"patchable":true,"blockers":[]}}`)) + return + } + json.NewDecoder(r.Body).Decode(&applied) + w.Write([]byte(`{"action":"noop","currentVersion":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","desiredVersion":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","changes":[]}`)) + })) + defer s.Close() + writeConfig(t, s.URL) + app, _ := testApp(t, d, s.Client()) + if err := execute(app, "link", "--service", "s"); err != nil { + t.Fatal(err) + } + loaded, err := manifest.Load(d) + if err != nil || !reflect.DeepEqual(loaded.Manifest.Service.Crons, []manifest.Cron{{Path: "/jobs/nightly", Schedule: "0 5 * * *"}}) { + t.Fatalf("crons=%#v err=%v", loaded.Manifest.Service.Crons, err) + } + if err := execute(app, "apply", "--yes"); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(applied["crons"], []any{map[string]any{"path": "/jobs/nightly", "schedule": "0 5 * * *"}}) { + t.Fatalf("applied crons=%#v", applied["crons"]) + } +} + func TestApplyPlacementPayloads(t *testing.T) { for _, tc := range []struct { name, yaml string diff --git a/cli/internal/cli/types.go b/cli/internal/cli/types.go index c3155b1b..8405faa4 100644 --- a/cli/internal/cli/types.go +++ b/cli/internal/cli/types.go @@ -51,6 +51,7 @@ type serviceItem struct { HealthCheck *manifest.HealthCheck `json:"healthCheck"` StartCommand *string `json:"startCommand"` Resources *manifest.Resources `json:"resources"` + Crons []manifest.Cron `json:"crons"` } type targetProject struct { ID string `json:"id"` diff --git a/cli/internal/manifest/manifest.go b/cli/internal/manifest/manifest.go index 876a6238..37aca305 100644 --- a/cli/internal/manifest/manifest.go +++ b/cli/internal/manifest/manifest.go @@ -37,6 +37,11 @@ type Service struct { HealthCheck *HealthCheck `json:"healthCheck" yaml:"healthCheck"` StartCommand *string `json:"startCommand" yaml:"startCommand"` Resources *Resources `json:"resources,omitempty" yaml:"resources,omitempty"` + Crons []Cron `json:"crons,omitempty" yaml:"crons,omitempty"` +} +type Cron struct { + Path string `json:"path" yaml:"path"` + Schedule string `json:"schedule" yaml:"schedule"` } type Placement struct { Mode string `json:"mode" yaml:"mode"` @@ -141,6 +146,10 @@ func ApplyDefaults(m *Manifest) { if m.Service.Ports == nil { m.Service.Ports = []Port{} } + for i := range m.Service.Crons { + m.Service.Crons[i].Path = strings.TrimSpace(m.Service.Crons[i].Path) + m.Service.Crons[i].Schedule = strings.TrimSpace(m.Service.Crons[i].Schedule) + } if m.Service.Replicas == 0 { m.Service.Replicas = 1 } @@ -307,6 +316,43 @@ func Validate(m Manifest) error { return errors.New("service.resources.memoryMb must be between 64 and 65536") } } + seenCronPaths := make(map[string]struct{}, len(m.Service.Crons)) + for i, cron := range m.Service.Crons { + if err := validateCronPath(cron.Path); err != nil { + return fmt.Errorf("service.crons[%d].path: %w", i, err) + } + if _, exists := seenCronPaths[cron.Path]; exists { + return fmt.Errorf("service.crons[%d].path must be unique", i) + } + seenCronPaths[cron.Path] = struct{}{} + if len(cron.Schedule) > 255 || len(strings.Fields(cron.Schedule)) != 5 { + return fmt.Errorf("service.crons[%d].schedule must be a five-field cron expression", i) + } + } + return nil +} + +func validateCronPath(value string) error { + if value == "" || len(value) > 2048 || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") { + return errors.New("must begin with exactly one slash and be at most 2048 characters") + } + decoded, err := url.PathUnescape(value) + if err != nil { + return errors.New("must contain valid URL escapes") + } + if !strings.HasPrefix(decoded, "/") || strings.HasPrefix(decoded, "//") || strings.ContainsAny(decoded, "?#\\") { + return errors.New("cannot contain a host override, query, fragment, or backslash") + } + for _, r := range decoded { + if r < 0x20 || r == 0x7f { + return errors.New("cannot contain control characters") + } + } + for _, segment := range strings.Split(decoded, "/") { + if segment == "." || segment == ".." { + return errors.New("cannot contain '.' or '..' segments") + } + } return nil } func (m Manifest) Linked() bool { diff --git a/cli/internal/manifest/manifest_test.go b/cli/internal/manifest/manifest_test.go index b7c5103a..365c808b 100644 --- a/cli/internal/manifest/manifest_test.go +++ b/cli/internal/manifest/manifest_test.go @@ -12,16 +12,37 @@ func base() Manifest { func TestDefaultsAndRoundTrip(t *testing.T) { m := base() m.Service.Replicas = 0 + m.Service.Crons = []Cron{{Path: " /api/cron/digest ", Schedule: " 0 5 * * * "}} b, e := Marshal(m) if e != nil { t.Fatal(e) } got, e := Parse(b) - if e != nil || got.Service.Replicas != 1 || got.Service.Ports == nil { + if e != nil || got.Service.Replicas != 1 || got.Service.Ports == nil || got.Service.Crons[0] != (Cron{Path: "/api/cron/digest", Schedule: "0 5 * * *"}) { t.Fatalf("got=%#v err=%v", got, e) } } +func TestCronValidation(t *testing.T) { + invalidPaths := []string{"//host/job", "/job?x=1", "/job#part", `/job\\next`, "/a/../b", "/a/%2e%2e/b", "/%2fhost", "/bad%zz", "/line%0Abreak"} + for _, path := range invalidPaths { + m := base() + m.Service.Crons = []Cron{{Path: path, Schedule: "0 5 * * *"}} + if err := Validate(m); err == nil { + t.Fatalf("invalid cron path %q accepted", path) + } + } + m := base() + m.Service.Crons = []Cron{{Path: "/jobs/nightly", Schedule: "0 5 * * *"}, {Path: "/jobs/nightly", Schedule: "0 6 * * *"}} + if err := Validate(m); err == nil || !strings.Contains(err.Error(), "unique") { + t.Fatalf("duplicate path error = %v", err) + } + m.Service.Crons = []Cron{{Path: "/jobs/nightly", Schedule: "0 5 * *"}} + if err := Validate(m); err == nil || !strings.Contains(err.Error(), "five-field") { + t.Fatalf("schedule error = %v", err) + } +} + func TestHostnameValidationAndSlugify(t *testing.T) { for _, value := range []string{"", "Upper", "two words", "-leading", "trailing-", "two--hyphens", strings.Repeat("a", 64)} { m := base() diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx index 6e1f93db..a97fb142 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx @@ -9,6 +9,7 @@ import { DeleteConfirmationDialog } from "@/components/core/delete-confirmation- import { EditableText } from "@/components/core/editable-text"; import { LocalDate } from "@/components/core/local-date"; import { ConfigSection } from "@/components/service/details/config-section"; +import { CronsSection } from "@/components/service/details/crons-section"; import { HealthCheckSection } from "@/components/service/details/health-check-section"; import { NetworkingSection } from "@/components/service/details/networking-section"; import { ReplicasSection } from "@/components/service/details/replicas-section"; @@ -119,6 +120,8 @@ export default function ConfigurationPage() { + +
diff --git a/web/app/api/inngest/route.ts b/web/app/api/inngest/route.ts index 99212b54..3f82d6a0 100644 --- a/web/app/api/inngest/route.ts +++ b/web/app/api/inngest/route.ts @@ -24,6 +24,8 @@ import { scheduledDeploymentsCheck, serviceDeletionWorkflow, serviceCommandRetention, + serviceCronDispatcher, + serviceCronWorkflow, serviceRestoreWorkflow, staleItemsCleanup, staleServerCheck, @@ -58,5 +60,7 @@ export const { GET, POST, PUT } = serve({ expiredDeletedServicesPurge, notificationDelivery, notificationRetention, + serviceCronDispatcher, + serviceCronWorkflow, ], }); diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts index cb44107a..9ad6b75f 100644 --- a/web/app/api/projects/[id]/services/route.ts +++ b/web/app/api/projects/[id]/services/route.ts @@ -12,6 +12,7 @@ import { servers, servicePorts, serviceReplicas, + serviceCrons, serviceRevisions, services, serviceVolumes, @@ -161,6 +162,20 @@ export async function GET( : and(eq(services.projectId, projectId), isNull(services.deletedAt)), ) .orderBy(services.createdAt); + const cronRows = + servicesList.length > 0 + ? await db + .select() + .from(serviceCrons) + .where( + inArray( + serviceCrons.serviceId, + servicesList.map((service) => service.id), + ), + ) + .orderBy(serviceCrons.path) + : []; + const cronsByService = Map.groupBy(cronRows, (cron) => cron.serviceId); const result = await Promise.all( servicesList.map(async (service) => { @@ -401,6 +416,7 @@ export async function GET( return { ...service, ports, + crons: cronsByService.get(service.id) ?? [], configuredReplicas: replicas, deployments: deploymentsWithDetails, secrets: serviceSecrets, diff --git a/web/app/api/services/[id]/logs/route.ts b/web/app/api/services/[id]/logs/route.ts index 49bcd40b..f4d3f314 100644 --- a/web/app/api/services/[id]/logs/route.ts +++ b/web/app/api/services/[id]/logs/route.ts @@ -34,7 +34,9 @@ export async function GET( const serverId = url.searchParams.get("serverId") || undefined; const logTypeParam = url.searchParams.get("type"); const logType = - logTypeParam === "container" || logTypeParam === "http" + logTypeParam === "container" || + logTypeParam === "http" || + logTypeParam === "cron" ? (logTypeParam as LogType) : undefined; diff --git a/web/components/service/details/crons-section.tsx b/web/components/service/details/crons-section.tsx new file mode 100644 index 00000000..3d7a34e7 --- /dev/null +++ b/web/components/service/details/crons-section.tsx @@ -0,0 +1,124 @@ +"use client"; + +import cronstrue from "cronstrue"; +import { Ban, CheckCircle2, XCircle } from "lucide-react"; +import { memo } from "react"; +import { LocalDate } from "@/components/core/local-date"; +import { ConfigSection } from "@/components/service/details/config-section"; +import { StatusBadge } from "@/components/ui/status-badge"; +import type { ServiceCron, ServiceWithDetails as Service } from "@/db/types"; + +const STATUS_CONFIG = { + succeeded: { + icon: CheckCircle2, + label: "Succeeded", + className: "text-green-500", + }, + failed: { icon: XCircle, label: "Failed", className: "text-red-500" }, + skipped: { icon: Ban, label: "Skipped", className: "text-orange-500" }, +} as const; + +function describeSchedule(schedule: string) { + try { + return cronstrue.toString(schedule, { verbose: true }); + } catch { + return "Invalid cron expression"; + } +} + +function CronStatus({ + status, +}: { + status: NonNullable; +}) { + const config = STATUS_CONFIG[status]; + return ( + + ); +} + +function formatDuration(durationMs: number) { + return durationMs < 1000 + ? `${durationMs}ms` + : `${(durationMs / 1000).toFixed(2)}s`; +} + +export const CronsSection = memo(function CronsSection({ + service, +}: { + service: Service; +}) { + const crons = service.crons ?? []; + + return ( + 0 ? `${crons.length}` : "None"} + summaryMuted={crons.length === 0} + > +
+

+ Cron jobs are managed in{" "} + techulus.yml. Schedules run in UTC + and are read-only here. +

+ + {crons.length === 0 ? ( +

+ No cron jobs configured. +

+ ) : ( +
+ {crons.map((cron) => ( +
+
+

+ {cron.path} +

+

{cron.schedule}

+

+ {describeSchedule(cron.schedule)} (UTC) +

+
+ + {cron.lastStatus ? ( +
+
+ + + + +
+
+ HTTP status: {cron.lastStatusCode ?? "—"} + + Duration:{" "} + {cron.lastDurationMs == null + ? "—" + : formatDuration(cron.lastDurationMs)} + +
+ {cron.lastError && ( +

+ {cron.lastError} +

+ )} +
+ ) : ( +

Never run.

+ )} +
+ ))} +
+ )} +
+
+ ); +}); diff --git a/web/db/schema.ts b/web/db/schema.ts index 82f23b87..5a80770d 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -602,6 +602,41 @@ export const services = pgTable( ], ); +export const serviceCrons = pgTable( + "service_crons", + { + id: text("id").primaryKey(), + serviceId: text("service_id") + .notNull() + .references(() => services.id, { onDelete: "cascade" }), + path: text("path").notNull(), + schedule: text("schedule").notNull(), + nextScheduledFor: timestamp("next_scheduled_for", { + withTimezone: true, + }).notNull(), + lastScheduledFor: timestamp("last_scheduled_for", { withTimezone: true }), + lastAttemptedFor: timestamp("last_attempted_for", { withTimezone: true }), + lastStartedAt: timestamp("last_started_at", { withTimezone: true }), + lastFinishedAt: timestamp("last_finished_at", { withTimezone: true }), + lastStatus: text("last_status", { + enum: ["succeeded", "failed", "skipped"], + }), + lastStatusCode: integer("last_status_code"), + lastDurationMs: integer("last_duration_ms"), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("service_crons_service_path_unique_idx").on( + table.serviceId, + table.path, + ), + index("service_crons_due_scan_idx").on(table.nextScheduledFor), + ], +); + export const serviceReplicas = pgTable( "service_replicas", { diff --git a/web/db/types.ts b/web/db/types.ts index 875fedf4..95849e6d 100644 --- a/web/db/types.ts +++ b/web/db/types.ts @@ -13,6 +13,7 @@ import type { servicePorts, serviceReplicas, serviceCommands, + serviceCrons, services, serviceVolumes, user, @@ -27,6 +28,7 @@ export type ServicePort = typeof servicePorts.$inferSelect; export type ServiceVolume = typeof serviceVolumes.$inferSelect; export type ServiceReplica = typeof serviceReplicas.$inferSelect; export type ServiceCommand = typeof serviceCommands.$inferSelect; +export type ServiceCron = typeof serviceCrons.$inferSelect; export type Secret = typeof secrets.$inferSelect; export type Deployment = typeof deployments.$inferSelect; export type DeploymentPort = typeof deploymentPorts.$inferSelect; @@ -47,6 +49,7 @@ export type ServiceWithDetails = Service & { activeConfig?: DeployedConfig | null; currentSource: SourceConfig; ports: ServicePort[]; + crons: ServiceCron[]; configuredReplicas: Array< ServiceReplica & { serverName: string; serverIsProxy: boolean } >; diff --git a/web/lib/inngest/events/index.ts b/web/lib/inngest/events/index.ts index 0bf59f8d..4e284e71 100644 --- a/web/lib/inngest/events/index.ts +++ b/web/lib/inngest/events/index.ts @@ -8,6 +8,7 @@ export type { ResourceEvents } from "./resource"; export type { RestoreEvents } from "./restore"; export type { RolloutEvents } from "./rollout"; export type { ServiceDeletionEvents } from "./service-deletion"; +export type { ServiceCronEvents } from "./service-cron"; import type { BackupEvents } from "./backup"; import type { BuildEvents } from "./build"; @@ -17,6 +18,7 @@ import type { ResourceEvents } from "./resource"; import type { RestoreEvents } from "./restore"; import type { RolloutEvents } from "./rollout"; import type { ServiceDeletionEvents } from "./service-deletion"; +import type { ServiceCronEvents } from "./service-cron"; export type Events = RolloutEvents & MigrationEvents & @@ -25,7 +27,8 @@ export type Events = RolloutEvents & BuildEvents & ServiceDeletionEvents & ResourceEvents & - NotificationEvents; + NotificationEvents & + ServiceCronEvents; type EventName = keyof Events & string; type EventData = Events[TName]["data"]; @@ -61,4 +64,5 @@ export const inngestEvents = { manifestCompleted: defineEvent("manifest/completed"), manifestFailed: defineEvent("manifest/failed"), notificationRequested: defineEvent("notification/requested"), + serviceCronExecute: defineEvent("service-cron/execute"), }; diff --git a/web/lib/inngest/events/service-cron.ts b/web/lib/inngest/events/service-cron.ts new file mode 100644 index 00000000..49fc3e4a --- /dev/null +++ b/web/lib/inngest/events/service-cron.ts @@ -0,0 +1,5 @@ +export type ServiceCronEvents = { + "service-cron/execute": { + data: { cronId: string; schedule: string; scheduledFor: string }; + }; +}; diff --git a/web/lib/inngest/functions/crons.ts b/web/lib/inngest/functions/crons.ts index ddcd433b..89b5a9b7 100644 --- a/web/lib/inngest/functions/crons.ts +++ b/web/lib/inngest/functions/crons.ts @@ -1,4 +1,7 @@ +import { and, asc, eq, isNull, lte } from "drizzle-orm"; import { cron } from "inngest"; +import { db } from "@/db"; +import { serviceCrons, services } from "@/db/schema"; import { cleanupExpiredChallenges, renewExpiringCertificates, @@ -19,6 +22,11 @@ import { runAutoscalingController, } from "@/lib/scheduler"; import { inngest } from "../client"; +import { + cronEventId, + latestDueOccurrence, + nextOccurrenceAfter, +} from "@/lib/service-crons"; export const staleServerCheck = inngest.createFunction( { @@ -206,3 +214,58 @@ export const serviceCommandRetention = inngest.createFunction( async ({ step }) => step.run("cleanup-service-commands", cleanupOldServiceCommands), ); + +export const serviceCronDispatcher = inngest.createFunction( + { + id: "cron-service-cron-dispatcher", + triggers: [cron("* * * * *")], + singleton: { mode: "skip" }, + }, + async ({ step }) => + step.run("dispatch-service-crons", async () => { + const now = new Date(); + const rows = await db + .select({ cron: serviceCrons }) + .from(serviceCrons) + .innerJoin( + services, + and( + eq(serviceCrons.serviceId, services.id), + isNull(services.deletedAt), + ), + ) + .where(lte(serviceCrons.nextScheduledFor, now)) + .orderBy(asc(serviceCrons.nextScheduledFor)) + .limit(100) + .then((results) => results.map(({ cron }) => cron)); + for (const row of rows) { + const occurrence = latestDueOccurrence( + row.schedule, + new Date(row.nextScheduledFor.getTime() - 1), + now, + ); + if (!occurrence) continue; + await inngest.send({ + id: cronEventId(row.id, occurrence), + name: "service-cron/execute", + data: { + cronId: row.id, + schedule: row.schedule, + scheduledFor: occurrence.toISOString(), + }, + }); + await db + .update(serviceCrons) + .set({ + lastScheduledFor: occurrence, + nextScheduledFor: nextOccurrenceAfter(row.schedule, now), + }) + .where( + and( + eq(serviceCrons.id, row.id), + eq(serviceCrons.nextScheduledFor, row.nextScheduledFor), + ), + ); + } + }), +); diff --git a/web/lib/inngest/functions/index.ts b/web/lib/inngest/functions/index.ts index 51caadfa..88a7b203 100644 --- a/web/lib/inngest/functions/index.ts +++ b/web/lib/inngest/functions/index.ts @@ -9,6 +9,7 @@ export { controlPlaneUpdateCheck, notificationRetention, serviceCommandRetention, + serviceCronDispatcher, oldBackupsCleanup, registryArtifactRetention, scheduledBackupsCheck, @@ -16,6 +17,7 @@ export { staleItemsCleanup, staleServerCheck, } from "./crons"; +export { serviceCronWorkflow } from "./service-cron-workflow"; export { migrationWorkflow } from "./migration-workflow"; export { notificationDelivery } from "./notification-delivery"; export { onDeploymentFailed } from "./on-deployment-failed"; diff --git a/web/lib/inngest/functions/service-cron-workflow.ts b/web/lib/inngest/functions/service-cron-workflow.ts new file mode 100644 index 00000000..3bec2513 --- /dev/null +++ b/web/lib/inngest/functions/service-cron-workflow.ts @@ -0,0 +1,20 @@ +import { executeServiceCron } from "@/lib/service-crons"; +import { inngest } from "../client"; +import { inngestEvents } from "../events"; + +export const serviceCronWorkflow = inngest.createFunction( + { + id: "service-cron-execute", + retries: 0, + concurrency: [{ limit: 1, key: "event.data.cronId" }], + triggers: [inngestEvents.serviceCronExecute], + }, + async ({ event, step }) => + step.run("execute-service-cron", () => + executeServiceCron( + event.data.cronId, + event.data.schedule, + new Date(event.data.scheduledFor), + ), + ), +); diff --git a/web/lib/public-api-routes.ts b/web/lib/public-api-routes.ts index 66234516..2ea2f759 100644 --- a/web/lib/public-api-routes.ts +++ b/web/lib/public-api-routes.ts @@ -133,6 +133,7 @@ export async function getServiceDetails( startCommand: current.startCommand, resources: current.resources.cpuCores == null ? null : current.resources, + crons: current.crons, }, management: configuration.management, }); diff --git a/web/lib/public-api.ts b/web/lib/public-api.ts index a6159dce..e9993ea1 100644 --- a/web/lib/public-api.ts +++ b/web/lib/public-api.ts @@ -1,5 +1,15 @@ import { createHash, randomUUID } from "node:crypto"; -import { and, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm"; +import { + and, + desc, + eq, + inArray, + isNull, + ne, + notInArray, + sql, +} from "drizzle-orm"; +import { CronExpressionParser } from "cron-parser"; import { z } from "zod"; import { db } from "@/db"; import { @@ -8,6 +18,7 @@ import { githubRepos, projects, servers, + serviceCrons, servicePorts, serviceReplicas, serviceRevisions, @@ -27,6 +38,59 @@ import { const githubPathPart = /^[A-Za-z0-9_.-]+$/; const windowsAbsolutePath = /^[A-Za-z]:[\\/]/; +export function nextOccurrenceAfter(schedule: string, after: Date): Date { + return CronExpressionParser.parse(schedule, { + currentDate: after, + tz: "UTC", + }) + .next() + .toDate(); +} + +export function isSafeCronPath(value: string): boolean { + if (!value.startsWith("/") || value.startsWith("//") || value.length > 2048) + return false; + let decoded: string; + try { + decoded = decodeURIComponent(value); + } catch { + return false; + } + if ( + !decoded.startsWith("/") || + decoded.startsWith("//") || + // eslint-disable-next-line no-control-regex -- Manifest paths must reject control characters. + /[?#\\\u0000-\u001f\u007f]/.test(decoded) + ) + return false; + return !decoded.split("/").some((part) => part === "." || part === ".."); +} + +export const publicCronSchema = z.strictObject({ + path: z + .string() + .trim() + .min(1) + .max(2048) + .refine(isSafeCronPath, "Invalid cron path"), + schedule: z + .string() + .trim() + .min(1) + .max(255) + .refine((value) => value.split(/\s+/).length === 5, { + message: "Schedule must contain exactly five cron fields", + }) + .refine((value) => { + try { + CronExpressionParser.parse(value, { tz: "UTC" }); + return true; + } catch { + return false; + } + }, "Invalid UTC cron expression"), +}); + export function canonicalGitHubRepository(value: string): string { let url: URL; try { @@ -314,7 +378,7 @@ function sanitizeSpec(specification: unknown) { } export async function safeConfiguration(service: NestedService) { - const [repo, ports, volumes, placements, activeDeployment] = + const [repo, ports, crons, volumes, placements, activeDeployment] = await Promise.all([ db .select() @@ -326,6 +390,10 @@ export async function safeConfiguration(service: NestedService) { .select() .from(servicePorts) .where(eq(servicePorts.serviceId, service.id)), + db + .select({ path: serviceCrons.path, schedule: serviceCrons.schedule }) + .from(serviceCrons) + .where(eq(serviceCrons.serviceId, service.id)), db .select({ name: serviceVolumes.name, @@ -374,6 +442,9 @@ export async function safeConfiguration(service: NestedService) { a.name.localeCompare(b.name, "en") || a.containerPath.localeCompare(b.containerPath, "en"), ); + const sortedCrons = crons.toSorted((a, b) => + a.path.localeCompare(b.path, "en"), + ); const replicaCount = getServiceTotalReplicas({ ...service, configuredReplicas: sortedPlacements, @@ -428,6 +499,7 @@ export async function safeConfiguration(service: NestedService) { tlsPassthrough: port.tlsPassthrough, })), volumes: sortedVolumes, + crons: sortedCrons, serverless: { enabled: service.serverlessEnabled, sleepAfterSeconds: service.serverlessSleepAfterSeconds, @@ -628,6 +700,16 @@ export const replaceConfigurationSchema = z.strictObject({ "CPU and memory limits must both be set or both be null", ) .nullable(), + crons: z + .array(publicCronSchema) + .max(100) + .superRefine((crons, context) => { + if (new Set(crons.map((cron) => cron.path)).size !== crons.length) + context.addIssue({ + code: "custom", + message: "Cron paths must be unique", + }); + }), }); type PublicApiDomainError = Error & { code: string; status: number }; @@ -664,6 +746,9 @@ function healthCheckFromService(service: NestedService) { } type ReplacementInput = z.infer; +type CanonicalReplacementInput = Omit & { + crons?: ReplacementInput["crons"]; +}; type ConfigurationChange = { field: string; from: unknown; to: unknown }; function canonicalPlanSource(source: PublicSource) { @@ -680,6 +765,7 @@ function canonicalReplacementState( source: ReturnType, ports: Array<{ port: number; isPublic: boolean; domain: string | null }>, placements: Array<{ serverId: string; count: number }>, + crons: Array<{ path: string; schedule: string }>, ) { const resources = service.resourceCpuLimit == null && service.resourceMemoryLimitMb == null @@ -726,11 +812,14 @@ function canonicalReplacementState( healthCheck: healthCheckFromService(service), startCommand: service.startCommand?.trim() || null, resources, + crons: crons + .map(({ path, schedule }) => ({ path, schedule })) + .toSorted((a, b) => a.path.localeCompare(b.path, "en")), serverless: { enabled: service.serverlessEnabled }, }; } -export function canonicalDesired(input: ReplacementInput) { +export function canonicalDesired(input: CanonicalReplacementInput) { return { ...input, source: canonicalPlanSource(input.source), @@ -762,6 +851,9 @@ export function canonicalDesired(input: ReplacementInput) { }, } : input.placement, + crons: (input.crons ?? []).toSorted((a, b) => + a.path.localeCompare(b.path, "en"), + ), }; } @@ -807,12 +899,19 @@ function configurationChanges( } export function planCanonicalConfiguration( - current: ReturnType, - desiredInput: ReplacementInput, + current: Omit, "crons"> & { + crons?: Array<{ path: string; schedule: string }>; + }, + desiredInput: CanonicalReplacementInput, ) { + const { serverless, ...currentWithoutServerless } = current; const canonicalCurrent = { - ...current, + ...currentWithoutServerless, source: canonicalPlanSource(current.source), + crons: (current.crons ?? []) + .map(({ path, schedule }) => ({ path, schedule })) + .toSorted((a, b) => a.path.localeCompare(b.path, "en")), + serverless, }; const desiredConfiguration = canonicalDesired(desiredInput); const desired = { @@ -880,7 +979,7 @@ async function replaceConfigurationInternal( .limit(1) .then((rows) => rows[0]); if (!persisted) domainError("Service not found", "NOT_FOUND", 404); - const [ports, volumes, placements, repo] = await Promise.all([ + const [ports, volumes, placements, repo, crons] = await Promise.all([ tx .select() .from(servicePorts) @@ -899,6 +998,10 @@ async function replaceConfigurationInternal( .where(eq(githubRepos.serviceId, service.id)) .limit(1) .then((rows) => rows[0]), + tx + .select() + .from(serviceCrons) + .where(eq(serviceCrons.serviceId, service.id)), ]); const source = resolvePersistedSourceFromRows(persisted, repo); const currentState = canonicalReplacementState( @@ -906,6 +1009,7 @@ async function replaceConfigurationInternal( source, ports, placements, + crons, ); if ( input.source.type === "image" && @@ -1300,6 +1404,42 @@ async function replaceConfigurationInternal( } } } + const cronsByPath = new Map(crons.map((cron) => [cron.path, cron])); + const appliedAt = new Date(); + for (const cron of input.crons) { + const existing = cronsByPath.get(cron.path); + if (!existing) { + await tx.insert(serviceCrons).values({ + id: randomUUID(), + serviceId: service.id, + ...cron, + nextScheduledFor: nextOccurrenceAfter(cron.schedule, appliedAt), + }); + } else if (existing.schedule !== cron.schedule) { + await tx + .update(serviceCrons) + .set({ + schedule: cron.schedule, + nextScheduledFor: nextOccurrenceAfter(cron.schedule, appliedAt), + }) + .where(eq(serviceCrons.id, existing.id)); + } + } + if (input.crons.length === 0) { + await tx + .delete(serviceCrons) + .where(eq(serviceCrons.serviceId, service.id)); + } else { + await tx.delete(serviceCrons).where( + and( + eq(serviceCrons.serviceId, service.id), + notInArray( + serviceCrons.path, + input.crons.map((cron) => cron.path), + ), + ), + ); + } return { targetServiceName: persisted.name, ...plan }; }); diff --git a/web/lib/service-crons.ts b/web/lib/service-crons.ts new file mode 100644 index 00000000..78d0d8db --- /dev/null +++ b/web/lib/service-crons.ts @@ -0,0 +1,362 @@ +import { lookup as dnsLookup } from "node:dns/promises"; +import * as http from "node:http"; +import * as https from "node:https"; +import { isIP } from "node:net"; +import { and, eq, inArray, isNull, lt, or } from "drizzle-orm"; +import { CronExpressionParser } from "cron-parser"; +import { Address4, Address6 } from "ip-address"; +import { db } from "@/db"; +import { secrets, serviceCrons, services } from "@/db/schema"; +import { decryptSecret } from "@/lib/crypto"; +import { isSafeCronPath, nextOccurrenceAfter } from "@/lib/public-api"; +import { ingestCronLog, type CronLog } from "@/lib/victoria-logs"; + +const MAX_ERROR = 500; +const EXECUTION_BUDGET_MS = 10_000; +const blockedV4 = [ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", +].map((value) => new Address4(value)); +const blockedV6 = ["2001::/23", "2001:db8::/32", "3fff::/20"].map( + (value) => new Address6(value), +); + +export type ResolvedAddress = { address: string; family: 4 | 6 }; +export type CronRequestResult = { + status: "succeeded" | "failed"; + statusCode: number | null; + error: string | null; +}; + +export { nextOccurrenceAfter }; + +export function latestDueOccurrence( + schedule: string, + cursor: Date, + now: Date, +): Date | null { + try { + const occurrence = CronExpressionParser.parse(schedule, { + currentDate: new Date(now.getTime() + 1), + tz: "UTC", + }) + .prev() + .toDate(); + return occurrence > cursor && occurrence <= now ? occurrence : null; + } catch { + return null; + } +} + +export function cronEventId(cronId: string, scheduledFor: Date): string { + return `service-cron:${cronId}:${scheduledFor.toISOString()}`; +} + +export function sanitizeCronError(error: unknown): string { + const message = + error instanceof Error ? error.message : "Cron request failed"; + // eslint-disable-next-line no-control-regex -- Strip unsafe control characters from persisted errors. + return message.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, MAX_ERROR); +} + +export function isGlobalAddress(value: string): boolean { + try { + if (isIP(value) === 4) { + const address = new Address4(value); + return !blockedV4.some((range) => address.isInSubnet(range)); + } + if (isIP(value) === 6) { + const address = new Address6(value); + if (address.is4()) return isGlobalAddress(address.to4().address); + return ( + address.isInSubnet(new Address6("2000::/3")) && + !blockedV6.some((range) => address.isInSubnet(range)) + ); + } + } catch {} + return false; +} + +export function parseCronUrl(base: string, path: string): URL { + if (!isSafeCronPath(path)) throw new Error("Invalid cron path"); + let url: URL; + try { + url = new URL(base); + } catch { + throw new Error("Invalid CRON_BASE_URL"); + } + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username || + url.password || + url.search || + url.hash + ) + throw new Error("Invalid CRON_BASE_URL"); + return new URL(path, `${url.origin}/`); +} + +export async function resolvePublicAddresses( + hostname: string, + lookup: ( + hostname: string, + options: { all: true; verbatim: true }, + ) => Promise = async (host, options) => + (await dnsLookup(host, options)).map((answer) => ({ + address: answer.address, + family: answer.family as 4 | 6, + })), +): Promise { + const literal = isIP(hostname); + let answers: ResolvedAddress[]; + try { + answers = literal + ? [{ address: hostname, family: literal as 4 | 6 }] + : await lookup(hostname, { all: true, verbatim: true }); + } catch { + throw new Error("DNS lookup failed"); + } + if ( + !answers.length || + answers.some(({ address }) => !isGlobalAddress(address)) + ) + throw new Error("Cron destination is not public"); + return answers; +} + +type RequestImpl = typeof http.request; +export function validateCronTransport(url: URL, secret?: string): void { + if (secret && url.protocol === "http:") + throw new Error("CRON_SECRET requires HTTPS"); +} + +export async function performCronGet( + url: URL, + addresses: ResolvedAddress[], + secret: string | undefined, + timeoutMs: number, + requestImpl: RequestImpl = url.protocol === "https:" + ? https.request + : http.request, +): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (result: CronRequestResult) => { + if (settled) return; + settled = true; + resolve(result); + }; + if (timeoutMs <= 0) + return finish({ + status: "failed", + statusCode: null, + error: "Cron request timed out", + }); + const requestOptions: http.RequestOptions & { autoSelectFamily: boolean } = + { + method: "GET", + agent: false, + autoSelectFamily: false, + headers: secret ? { Authorization: `Bearer ${secret}` } : undefined, + lookup: (_hostname, options, callback) => { + const selected = addresses[0]; + if (options?.all) callback(null, addresses); + else callback(null, selected.address, selected.family); + }, + ...(url.protocol === "https:" ? { servername: url.hostname } : {}), + }; + const req = requestImpl(url, requestOptions, (response) => { + const code = response.statusCode ?? null; + response.destroy(); + finish( + code != null && code >= 200 && code < 300 + ? { status: "succeeded", statusCode: code, error: null } + : { + status: "failed", + statusCode: code, + error: `HTTP status ${code ?? "unknown"}`, + }, + ); + }); + const timer = setTimeout(() => { + finish({ + status: "failed", + statusCode: null, + error: "Cron request timed out", + }); + req.destroy(); + }, timeoutMs); + timer.unref?.(); + req.once("error", () => + finish({ + status: "failed", + statusCode: null, + error: "Cron request failed", + }), + ); + req.once("close", () => { + clearTimeout(timer); + if (!settled) + finish({ + status: "failed", + statusCode: null, + error: "Cron request closed", + }); + }); + req.end(); + }); +} + +async function withinDeadline( + promise: Promise, + deadline: number, +): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("Cron request timed out"); + let timer: ReturnType; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("Cron request timed out")), + remaining, + ); + }), + ]); + } finally { + clearTimeout(timer!); + } +} + +export async function executeServiceCron( + cronId: string, + schedule: string, + scheduledFor: Date, +) { + const deadline = Date.now() + EXECUTION_BUDGET_MS; + const row = await db + .select({ cron: serviceCrons, serviceId: services.id }) + .from(serviceCrons) + .innerJoin( + services, + and(eq(serviceCrons.serviceId, services.id), isNull(services.deletedAt)), + ) + .where(eq(serviceCrons.id, cronId)) + .limit(1) + .then((rows) => rows[0]); + if (!row || row.cron.schedule !== schedule) return { stale: true as const }; + const startedAt = new Date(); + const claimed = await db + .update(serviceCrons) + .set({ lastAttemptedFor: scheduledFor, lastStartedAt: startedAt }) + .where( + and( + eq(serviceCrons.id, cronId), + eq(serviceCrons.schedule, schedule), + or( + isNull(serviceCrons.lastAttemptedFor), + lt(serviceCrons.lastAttemptedFor, scheduledFor), + ), + ), + ) + .returning({ id: serviceCrons.id }); + if (!claimed.length) return { stale: true as const }; + let status: "succeeded" | "failed" | "skipped" = "skipped"; + let statusCode: number | null = null; + let error: string | null = null; + let base = ""; + let secret: string | undefined; + try { + const values = await db + .select() + .from(secrets) + .where( + and( + eq(secrets.serviceId, row.serviceId), + inArray(secrets.key, ["CRON_BASE_URL", "CRON_SECRET"]), + ), + ); + const encrypted = new Map( + values.map((value) => [value.key, value.encryptedValue]), + ); + base = encrypted.get("CRON_BASE_URL") + ? await decryptSecret(encrypted.get("CRON_BASE_URL")!) + : ""; + secret = encrypted.get("CRON_SECRET") + ? await decryptSecret(encrypted.get("CRON_SECRET")!) + : undefined; + } catch { + status = "failed"; + error = "Cron configuration could not be loaded"; + } + if (error === null) { + try { + if (!base.trim()) throw new Error("CRON_BASE_URL is not configured"); + const url = parseCronUrl(base.trim(), row.cron.path); + validateCronTransport(url, secret); + try { + const addresses = await withinDeadline( + resolvePublicAddresses(url.hostname), + deadline, + ); + ({ status, statusCode, error } = await performCronGet( + url, + addresses, + secret, + deadline - Date.now(), + )); + } catch (cause) { + const message = sanitizeCronError(cause); + status = + message === "Cron destination is not public" ? "skipped" : "failed"; + error = message; + } + } catch (cause) { + error = sanitizeCronError(cause); + status = "skipped"; + } + } + const finishedAt = new Date(); + const durationMs = Math.max(0, finishedAt.getTime() - startedAt.getTime()); + await db + .update(serviceCrons) + .set({ + lastFinishedAt: finishedAt, + lastStatus: status, + lastStatusCode: statusCode, + lastDurationMs: durationMs, + lastError: error, + }) + .where(eq(serviceCrons.id, cronId)); + const log: CronLog = { + _msg: `Cron ${status}`, + _time: finishedAt.toISOString(), + service_id: row.serviceId, + cron_id: cronId, + path: row.cron.path, + scheduled_for: scheduledFor.toISOString(), + started_at: startedAt.toISOString(), + finished_at: finishedAt.toISOString(), + result: status, + status: statusCode, + duration_ms: durationMs, + error, + log_type: "cron", + }; + await ingestCronLog(log); + return { stale: false as const, status, statusCode, error }; +} diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts index 6fd293f3..482e5f6a 100644 --- a/web/lib/victoria-logs.ts +++ b/web/lib/victoria-logs.ts @@ -20,7 +20,7 @@ function getQueryEndpoint(): EndpointConfig | undefined { return parseEndpoint(endpoint); } -export type LogType = "container" | "http"; +export type LogType = "container" | "http" | "cron"; type LogSearchField = "_msg" | "path" | "method" | "status" | "client_ip"; export type StoredLog = { @@ -114,8 +114,10 @@ function buildServiceLogFilter(options: QueryLogsByServiceOptions): string { let query = formatLogSqlExactFilter("service_id", serviceId); if (logType === "http") { query += ` log_type:http`; + } else if (logType === "cron") { + query += ` log_type:cron`; } else if (logType === "container") { - query += ` -log_type:http -log_type:build -log_type:rollout`; + query += ` -log_type:http -log_type:build -log_type:rollout -log_type:cron`; } else { query += ` -log_type:build -log_type:rollout`; } @@ -354,6 +356,42 @@ export type RolloutLog = { log_type: "rollout"; }; +export type CronLog = { + _msg: string; + _time: string; + service_id: string; + cron_id: string; + path: string; + scheduled_for: string; + started_at: string; + finished_at: string; + result: "succeeded" | "failed" | "skipped"; + status: number | null; + duration_ms: number; + error: string | null; + log_type: "cron"; +}; + +export async function ingestCronLog(entry: CronLog): Promise { + try { + const endpoint = getQueryEndpoint(); + if (!endpoint) return; + const options = buildFetchOptions(endpoint); + await fetch(`${endpoint.url}/insert/jsonline`, { + ...options, + method: "POST", + body: `${JSON.stringify(entry)}\n`, + headers: { + ...((options.headers as Record) || {}), + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(5_000), + }); + } catch { + // Execution history is best-effort and must not affect the request result. + } +} + export async function ingestRolloutLog( rolloutId: string, serviceId: string, diff --git a/web/tests/inngest-route.test.ts b/web/tests/inngest-route.test.ts index 316bd55d..e532acea 100644 --- a/web/tests/inngest-route.test.ts +++ b/web/tests/inngest-route.test.ts @@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => { scheduledBackupsCheck: { id: "scheduled-backups-check" }, scheduledDeploymentsCheck: { id: "scheduled-deployments-check" }, serviceCommandRetention: { id: "service-command-retention" }, + serviceCronDispatcher: { id: "service-cron-dispatcher" }, + serviceCronWorkflow: { id: "service-cron-workflow" }, serviceDeletionWorkflow: { id: "service-deletion-workflow" }, serviceRestoreWorkflow: { id: "service-restore-workflow" }, staleItemsCleanup: { id: "stale-items-cleanup" }, diff --git a/web/tests/log-routes.test.ts b/web/tests/log-routes.test.ts index 144c1031..e53f9146 100644 --- a/web/tests/log-routes.test.ts +++ b/web/tests/log-routes.test.ts @@ -69,6 +69,18 @@ describe("log routes", () => { }); }); + it("accepts cron as a service log type", async () => { + const { GET, queryLogsByService } = await loadServiceLogsRoute(); + const response = await GET( + new Request("http://localhost/api/services/service-1/logs?type=cron"), + { params: Promise.resolve({ id: "service-1" }) }, + ); + expect(response.status).toBe(200); + expect(queryLogsByService).toHaveBeenCalledWith( + expect.objectContaining({ logType: "cron" }), + ); + }); + it("passes a validated after cursor to the deployment query", async () => { const { GET, queryLogsByDeployment } = await loadDeploymentLogsRoute(); const cursor = "2026-07-10T01:02:03Z"; diff --git a/web/tests/public-api-configuration.test.ts b/web/tests/public-api-configuration.test.ts index 3d01ccef..ad890425 100644 --- a/web/tests/public-api-configuration.test.ts +++ b/web/tests/public-api-configuration.test.ts @@ -34,6 +34,7 @@ describe("public API configuration state", () => { [], [], [], + [], [{ serverId: "server-1", serverName: "Sydney", count: 1 }], [{ id: "deployment-1", revisionId: "revision-1" }], [ diff --git a/web/tests/public-api-plan.test.ts b/web/tests/public-api-plan.test.ts index c9813780..a1939ee5 100644 --- a/web/tests/public-api-plan.test.ts +++ b/web/tests/public-api-plan.test.ts @@ -135,6 +135,80 @@ describe("configuration plan protocol", () => { ).toEqual(["a", "z"]); }); + it("sorts crons by path and includes schedule changes in the plan", () => { + const desired = canonicalDesired({ + name: "web", + source: { type: "image", image: "nginx" }, + hostname: "web", + ports: [], + placement: { mode: "automatic", replicas: 1 }, + healthCheck: null, + startCommand: null, + resources: null, + crons: [ + { path: "/z", schedule: "0 5 * * *" }, + { path: "/a", schedule: "0 6 * * *" }, + ], + }); + expect(desired.crons.map((cron) => cron.path)).toEqual(["/a", "/z"]); + + const result = planCanonicalConfiguration( + { + ...desired, + serverless: { enabled: false }, + crons: [{ path: "/a", schedule: "0 7 * * *" }], + }, + { ...desired, source: { type: "image", image: "nginx" } }, + ); + expect(result.changes).toContainEqual({ + field: "crons", + from: [{ path: "/a", schedule: "0 7 * * *" }], + to: desired.crons, + }); + }); + + it("excludes cron runtime summaries from configuration identity", () => { + const definition = { path: "/job", schedule: "0 5 * * *" }; + const currentCron = { + ...definition, + id: "cron-1", + lastStatus: "succeeded", + lastStatusCode: 204, + }; + const failedCurrentCron = { + ...currentCron, + lastStatus: "failed", + lastStatusCode: 500, + }; + const base = { + name: "web", + source: { type: "image" as const, image: "nginx" }, + hostname: "web", + ports: [], + placement: { mode: "automatic" as const, replicas: 1 }, + healthCheck: null, + startCommand: null, + resources: null, + }; + + const first = planCanonicalConfiguration( + { ...base, crons: [currentCron], serverless: { enabled: false } }, + { ...base, crons: [definition] }, + ); + const second = planCanonicalConfiguration( + { + ...base, + crons: [failedCurrentCron], + serverless: { enabled: false }, + }, + { ...base, crons: [definition] }, + ); + + expect(first.action).toBe("noop"); + expect(first.changes).toEqual([]); + expect(second.currentVersion).toBe(first.currentVersion); + }); + it("reports every managed field change, including removals and null clears", () => { const current = { name: "old-web", diff --git a/web/tests/public-api-source.test.ts b/web/tests/public-api-source.test.ts index 202f880f..d758ed99 100644 --- a/web/tests/public-api-source.test.ts +++ b/web/tests/public-api-source.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { canonicalGitHubRepository, + isSafeCronPath, isSafeRepositoryRoot, publicSourceSchema, replaceConfigurationSchema, @@ -15,6 +16,7 @@ const completeConfiguration = (overrides: Record = {}) => ({ healthCheck: null, startCommand: null, resources: null, + crons: [], ...overrides, }); @@ -194,3 +196,52 @@ describe("public API placement schema", () => { ).toBe(false); }); }); + +describe("public API cron schema", () => { + it.each([ + "//host/job", + "/job?x=1", + "/job#part", + "/job\\next", + "/a/../b", + "/a/%2e%2e/b", + "/%2fhost", + "/bad%zz", + "/line%0Abreak", + ])("rejects unsafe cron path %j", (path) => { + expect(isSafeCronPath(path)).toBe(false); + }); + + it("accepts valid unique five-field UTC cron definitions", () => { + expect( + replaceConfigurationSchema.safeParse( + completeConfiguration({ + crons: [{ path: "/jobs/nightly", schedule: "0 5 * * *" }], + }), + ).success, + ).toBe(true); + }); + + it.each([ + [{ path: "/jobs/nightly", schedule: "0 5 * *" }, "non-five-field schedule"], + [ + { path: "/jobs/nightly", schedule: "99 5 * * *" }, + "invalid cron expression", + ], + ])("rejects %s", (cron) => { + expect( + replaceConfigurationSchema.safeParse( + completeConfiguration({ crons: [cron] }), + ).success, + ).toBe(false); + }); + + it("rejects duplicate paths", () => { + const cron = { path: "/jobs/nightly", schedule: "0 5 * * *" }; + expect( + replaceConfigurationSchema.safeParse( + completeConfiguration({ crons: [cron, cron] }), + ).success, + ).toBe(false); + }); +}); diff --git a/web/tests/service-crons.test.ts b/web/tests/service-crons.test.ts new file mode 100644 index 00000000..351f054e --- /dev/null +++ b/web/tests/service-crons.test.ts @@ -0,0 +1,213 @@ +import { EventEmitter } from "node:events"; +import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { + cronEventId, + isGlobalAddress, + latestDueOccurrence, + nextOccurrenceAfter, + parseCronUrl, + performCronGet, + resolvePublicAddresses, + validateCronTransport, +} from "@/lib/service-crons"; + +describe("service cron scheduling and SSRF validation", () => { + it("returns the first UTC occurrence strictly after the supplied instant", () => { + expect( + nextOccurrenceAfter( + "0 5 * * *", + new Date("2026-08-06T05:00:00.000Z"), + ).toISOString(), + ).toBe("2026-08-07T05:00:00.000Z"); + expect( + nextOccurrenceAfter( + "30 23 * * *", + new Date("2026-08-06T22:00:00-04:00"), + ).toISOString(), + ).toBe("2026-08-07T23:30:00.000Z"); + }); + + it("coalesces missed intervals to the latest UTC occurrence", () => { + const due = latestDueOccurrence( + "* * * * *", + new Date("2000-01-01Z"), + new Date("2026-08-06T10:05:30Z"), + ); + expect(due?.toISOString()).toBe("2026-08-06T10:05:00.000Z"); + expect(latestDueOccurrence("* * * * *", due!, due!)).toBeNull(); + expect(cronEventId("cron-1", due!)).toBe(cronEventId("cron-1", due!)); + }); + + it.each([ + ["ftp://example.com", "/job"], + ["https://user:pass@example.com", "/job"], + ["https://example.com?x=1", "/job"], + ["https://example.com#x", "/job"], + ["https://example.com", "//host/job"], + ["https://example.com", "/a/%2e%2e/job"], + ])("rejects unsafe URL %s %s", (base, path) => + expect(() => parseCronUrl(base, path)).toThrow(), + ); + + it("joins a safe path while preserving the origin hostname", () => { + expect(parseCronUrl("https://example.com", "/jobs/nightly").href).toBe( + "https://example.com/jobs/nightly", + ); + }); + + it.each([ + "10.0.0.1", + "127.0.0.1", + "169.254.1.1", + "192.0.2.1", + "192.88.99.1", + "::1", + "fc00::1", + "fe80::1", + "2001::1", + "2001:db8::1", + "3fff::1", + "::ffff:10.0.0.1", + ])("rejects non-global address %s", (address) => + expect(isGlobalAddress(address)).toBe(false), + ); + + it("rejects mixed DNS results", async () => { + const lookup = vi.fn(async () => [ + { address: "8.8.8.8", family: 4 as const }, + { address: "10.0.0.1", family: 4 as const }, + ]); + await expect(resolvePublicAddresses("example.com", lookup)).rejects.toThrow( + "not public", + ); + }); + + it("rejects a secret over cleartext HTTP", () => { + expect(() => + validateCronTransport(new URL("http://example.com/job"), "not-logged"), + ).toThrow("requires HTTPS"); + expect(() => + validateCronTransport(new URL("http://example.com/job")), + ).not.toThrow(); + }); + + it.each([false, true])( + "pins lookup for all=%s without pooling", + async (all) => { + let options!: RequestOptions & { autoSelectFamily?: boolean }; + const request = fakeRequest(204, (received) => { + options = received; + }); + const result = performCronGet( + new URL("https://example.com/job"), + [ + { address: "8.8.8.8", family: 4 }, + { address: "2001:4860:4860::8888", family: 6 }, + ], + undefined, + 1_000, + request, + ); + const callback = vi.fn(); + options.lookup!("example.com", { all }, callback); + if (all) + expect(callback).toHaveBeenCalledWith(null, [ + { address: "8.8.8.8", family: 4 }, + { address: "2001:4860:4860::8888", family: 6 }, + ]); + else expect(callback).toHaveBeenCalledWith(null, "8.8.8.8", 4); + expect(options).toMatchObject({ + agent: false, + autoSelectFamily: false, + servername: "example.com", + }); + expect(await result).toEqual({ + status: "succeeded", + statusCode: 204, + error: null, + }); + expect(request).toHaveBeenCalledTimes(1); + }, + ); + + it.each([302, 404])( + "classifies HTTP %s at headers without retaining a body", + async (code) => { + let destroyed = false; + const request = fakeRequest(code, undefined, () => { + destroyed = true; + }); + await expect( + performCronGet( + new URL("https://example.com/job"), + [{ address: "8.8.8.8", family: 4 }], + undefined, + 1_000, + request, + ), + ).resolves.toMatchObject({ status: "failed", statusCode: code }); + expect(destroyed).toBe(true); + expect(request).toHaveBeenCalledTimes(1); + }, + ); + + it("settles once when the absolute request timeout expires", async () => { + vi.useFakeTimers(); + try { + const request = vi.fn(() => { + const req = new EventEmitter() as ClientRequest; + req.end = vi.fn(() => req) as unknown as ClientRequest["end"]; + req.destroy = vi.fn(() => req) as ClientRequest["destroy"]; + return req; + }) as unknown as typeof import("node:http").request; + const result = performCronGet( + new URL("https://example.com/job"), + [{ address: "8.8.8.8", family: 4 }], + undefined, + 25, + request, + ); + await vi.advanceTimersByTimeAsync(25); + await expect(result).resolves.toEqual({ + status: "failed", + statusCode: null, + error: "Cron request timed out", + }); + expect(request).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); + +function fakeRequest( + statusCode: number, + onOptions?: ( + options: RequestOptions & { autoSelectFamily?: boolean }, + ) => void, + onResponseDestroy?: () => void, +) { + return vi.fn( + ( + _url: URL, + options: RequestOptions, + callback: (response: IncomingMessage) => void, + ) => { + onOptions?.(options); + const req = new EventEmitter() as ClientRequest; + req.end = vi.fn(() => { + const response = new EventEmitter() as IncomingMessage; + response.statusCode = statusCode; + response.destroy = vi.fn(() => { + onResponseDestroy?.(); + return response; + }) as IncomingMessage["destroy"]; + callback(response); + return req; + }) as unknown as ClientRequest["end"]; + req.destroy = vi.fn(() => req) as ClientRequest["destroy"]; + return req; + }, + ) as unknown as typeof import("node:http").request; +} diff --git a/web/tests/victoria-logs.test.ts b/web/tests/victoria-logs.test.ts index 4e2b62f3..070b8a03 100644 --- a/web/tests/victoria-logs.test.ts +++ b/web/tests/victoria-logs.test.ts @@ -356,6 +356,66 @@ describe("VictoriaLogs queries", () => { expect(query).toContain('_msg:~"(?i)connection lost"'); } }); + + it("isolates cron, HTTP, and container service log filters", async () => { + const { queryLogsByService } = await loadVictoriaLogs(); + const queries: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + queries.push(new URL(String(input)).searchParams.get("query") || ""); + return jsonLinesResponse([]); + }), + ); + await queryLogsByService({ + serviceId: "service-1", + limit: 1, + logType: "cron", + }); + await queryLogsByService({ + serviceId: "service-1", + limit: 1, + logType: "http", + }); + await queryLogsByService({ + serviceId: "service-1", + limit: 1, + logType: "container", + }); + expect(queries[0]).toContain("log_type:cron"); + expect(queries[1]).toContain("log_type:http"); + expect(queries[2]).toContain("-log_type:cron"); + }); + + it("ingests only supplied cron metadata with a five-second deadline", async () => { + const { ingestCronLog } = await loadVictoriaLogs(); + const fetchMock = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + vi.stubGlobal("fetch", fetchMock); + await ingestCronLog({ + _msg: "Cron succeeded", + _time: "2026-08-06T10:00:01Z", + service_id: "service-1", + cron_id: "cron-1", + path: "/job", + scheduled_for: "2026-08-06T10:00:00Z", + started_at: "2026-08-06T10:00:00Z", + finished_at: "2026-08-06T10:00:01Z", + result: "succeeded", + status: 204, + duration_ms: 1000, + error: null, + log_type: "cron", + }); + const [url, init] = fetchMock.mock.calls[0]!; + expect(String(url)).toBe("http://victoria.test/insert/jsonline"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init?.body).toContain('"status":204'); + expect(init?.body).not.toContain("Authorization"); + expect(init?.body).not.toContain("CRON_BASE_URL"); + }); }); async function loadVictoriaLogs() { From a161028946359e06b5dae7f235d340c8c3ab7e07 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 08:28:51 +0000 Subject: [PATCH 4/8] Preserve version-tag image fallbacks Amp-Thread-ID: https://ampcode.com/threads/T-019fd59c-389b-7371-a65f-cf0c68fb7ebf Co-authored-by: Arjun Komath --- deployment/compose.postgres.yml | 8 ++++---- deployment/compose.production.yml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/deployment/compose.postgres.yml b/deployment/compose.postgres.yml index e92ccbda..5aa6b35d 100644 --- a/deployment/compose.postgres.yml +++ b/deployment/compose.postgres.yml @@ -70,7 +70,7 @@ services: restart: unless-stopped migrate: - image: ${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:tip} + image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}}" env_file: - ./.env environment: @@ -96,7 +96,7 @@ services: restart: on-failure web: - image: ${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:tip} + image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}}" scale: ${WEB_REPLICAS:-1} env_file: - ./.env @@ -147,7 +147,7 @@ services: restart: unless-stopped control-plane-updater: - image: ${TECHULUS_CLOUD_UPDATER_IMAGE:-ghcr.io/techulus/cloud/updater:tip} + image: "${TECHULUS_CLOUD_UPDATER_IMAGE:-ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:-tip}}" environment: - DEPLOY_DIR=/opt/techulus-cloud - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} @@ -163,7 +163,7 @@ services: restart: unless-stopped registry: - image: ${TECHULUS_CLOUD_REGISTRY_IMAGE:-ghcr.io/techulus/cloud/registry:tip} + image: "${TECHULUS_CLOUD_REGISTRY_IMAGE:-ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:-tip}}" env_file: - ./.env volumes: diff --git a/deployment/compose.production.yml b/deployment/compose.production.yml index cce03c72..54d3a496 100644 --- a/deployment/compose.production.yml +++ b/deployment/compose.production.yml @@ -52,7 +52,7 @@ services: restart: unless-stopped migrate: - image: ${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:tip} + image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}}" env_file: - ./.env environment: @@ -75,7 +75,7 @@ services: restart: on-failure web: - image: ${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:tip} + image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}}" scale: ${WEB_REPLICAS:-1} env_file: - ./.env @@ -126,7 +126,7 @@ services: restart: unless-stopped control-plane-updater: - image: ${TECHULUS_CLOUD_UPDATER_IMAGE:-ghcr.io/techulus/cloud/updater:tip} + image: "${TECHULUS_CLOUD_UPDATER_IMAGE:-ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:-tip}}" environment: - DEPLOY_DIR=/opt/techulus-cloud - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} @@ -142,7 +142,7 @@ services: restart: unless-stopped registry: - image: ${TECHULUS_CLOUD_REGISTRY_IMAGE:-ghcr.io/techulus/cloud/registry:tip} + image: "${TECHULUS_CLOUD_REGISTRY_IMAGE:-ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:-tip}}" env_file: - ./.env volumes: From 8f3dace82f36b6b36dd2ea53368984dc325ea538 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 08:29:51 +0000 Subject: [PATCH 5/8] fix server details skeleton widths Amp-Thread-ID: https://ampcode.com/threads/T-019fd613-9def-73aa-964f-f6f30199ef55 Co-authored-by: Arjun Komath --- .../dashboard/servers/[id]/loading.tsx | 15 ++------------- .../dashboard/servers/[id]/metrics/loading.tsx | 7 +++++++ .../dashboard/servers/[id]/security/loading.tsx | 7 +++++++ .../dashboard/servers/[id]/settings/loading.tsx | 5 +++++ .../server/server-details-skeleton.tsx | 17 +++++++++++++++++ 5 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 web/app/(dashboard)/dashboard/servers/[id]/metrics/loading.tsx create mode 100644 web/app/(dashboard)/dashboard/servers/[id]/security/loading.tsx create mode 100644 web/app/(dashboard)/dashboard/servers/[id]/settings/loading.tsx create mode 100644 web/components/server/server-details-skeleton.tsx diff --git a/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx index cfbdd5e2..19a2c2e1 100644 --- a/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx +++ b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx @@ -1,16 +1,5 @@ -import { Skeleton } from "@/components/ui/skeleton"; +import { ServerDetailsSkeleton } from "@/components/server/server-details-skeleton"; export default function Loading() { - return ( - <> - -
- Loading server details -
- - ); + return ; } diff --git a/web/app/(dashboard)/dashboard/servers/[id]/metrics/loading.tsx b/web/app/(dashboard)/dashboard/servers/[id]/metrics/loading.tsx new file mode 100644 index 00000000..616d97b6 --- /dev/null +++ b/web/app/(dashboard)/dashboard/servers/[id]/metrics/loading.tsx @@ -0,0 +1,7 @@ +import { ServerDetailsSkeleton } from "@/components/server/server-details-skeleton"; + +export default function Loading() { + return ( + + ); +} diff --git a/web/app/(dashboard)/dashboard/servers/[id]/security/loading.tsx b/web/app/(dashboard)/dashboard/servers/[id]/security/loading.tsx new file mode 100644 index 00000000..616d97b6 --- /dev/null +++ b/web/app/(dashboard)/dashboard/servers/[id]/security/loading.tsx @@ -0,0 +1,7 @@ +import { ServerDetailsSkeleton } from "@/components/server/server-details-skeleton"; + +export default function Loading() { + return ( + + ); +} diff --git a/web/app/(dashboard)/dashboard/servers/[id]/settings/loading.tsx b/web/app/(dashboard)/dashboard/servers/[id]/settings/loading.tsx new file mode 100644 index 00000000..8c7b68ea --- /dev/null +++ b/web/app/(dashboard)/dashboard/servers/[id]/settings/loading.tsx @@ -0,0 +1,5 @@ +import { ServerDetailsSkeleton } from "@/components/server/server-details-skeleton"; + +export default function Loading() { + return ; +} diff --git a/web/components/server/server-details-skeleton.tsx b/web/components/server/server-details-skeleton.tsx new file mode 100644 index 00000000..fd64eadc --- /dev/null +++ b/web/components/server/server-details-skeleton.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +export function ServerDetailsSkeleton({ className }: { className: string }) { + return ( + <> + +
+ Loading server details +
+ + ); +} From f67ff163de8da3832c98e6a64fb9ead86e4fd9d9 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 08:46:02 +0000 Subject: [PATCH 6/8] Install the latest verified release by default Amp-Thread-ID: https://ampcode.com/threads/T-019fd59c-389b-7371-a65f-cf0c68fb7ebf Co-authored-by: Arjun Komath --- deployment/.env.example | 9 ++- deployment/install.sh | 165 ++++++++++++++++++++++++++++++++++++---- docs/installation.mdx | 5 ++ 3 files changed, 165 insertions(+), 14 deletions(-) diff --git a/deployment/.env.example b/deployment/.env.example index dca92e4c..368c67f7 100644 --- a/deployment/.env.example +++ b/deployment/.env.example @@ -42,7 +42,14 @@ INNGEST_EVENT_KEY=xxx # Control plane deployment # Use compose.production.yml for external PostgreSQL. COMPOSE_FILE=compose.production.yml -TECHULUS_CLOUD_VERSION=v0.0.0 +# Omit TECHULUS_CLOUD_VERSION to install GitHub's latest release. +# Set it to a specific vX.Y.Z release or to tip for the rolling channel. +# TECHULUS_CLOUD_VERSION=vX.Y.Z +# The installer and updater populate immutable release image references. +# When omitted, Compose falls back to TECHULUS_CLOUD_VERSION. +# TECHULUS_CLOUD_WEB_IMAGE=ghcr.io/techulus/cloud/web@sha256:... +# TECHULUS_CLOUD_REGISTRY_IMAGE=ghcr.io/techulus/cloud/registry@sha256:... +# TECHULUS_CLOUD_UPDATER_IMAGE=ghcr.io/techulus/cloud/updater@sha256:... CONTROL_PLANE_UPDATER_TOKEN=generate-with-openssl-rand-hex-32 # Server error tracking (optional) diff --git a/deployment/install.sh b/deployment/install.sh index 403e727c..a12db9a8 100755 --- a/deployment/install.sh +++ b/deployment/install.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -RAW_URL="https://raw.githubusercontent.com/techulus/cloud/main/deployment" +GITHUB_LATEST_RELEASE_URL="https://api.github.com/repos/techulus/cloud/releases/latest" +RELEASE_BASE_URL="https://github.com/techulus/cloud/releases/download" +RAW_BASE_URL="https://raw.githubusercontent.com/techulus/cloud" DEPLOY_DIR="/opt/techulus-cloud" RED='\033[0;31m' @@ -231,15 +233,111 @@ EOF log_success "Docker log rotation configured" } +ensure_jq() { + if command -v jq &>/dev/null; then + return + fi + + log_info "Installing jq for release manifest verification..." + if [[ "$OS_FAMILY" == "debian" ]]; then + apt-get update -qq + apt-get install -y -qq jq >/dev/null + elif command -v dnf &>/dev/null; then + dnf install -y -q jq >/dev/null + else + yum install -y -q jq >/dev/null + fi +} + +requested_version() { + if [[ -n "${TECHULUS_CLOUD_VERSION:-}" ]]; then + printf '%s' "$TECHULUS_CLOUD_VERSION" + return + fi + if [[ -n "$ENV_FILE" && -f "$ENV_FILE" ]]; then + grep -E '^TECHULUS_CLOUD_VERSION=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true + fi +} + download_compose_files() { log_header "Downloading Compose Files" mkdir -p "$DEPLOY_DIR" - curl -fsSL "${RAW_URL}/compose.production.yml" -o "${DEPLOY_DIR}/compose.production.yml" - curl -fsSL "${RAW_URL}/compose.postgres.yml" -o "${DEPLOY_DIR}/compose.postgres.yml" + local target_version temp_dir + target_version="$(requested_version)" + if [[ "$target_version" == "tip" ]]; then + temp_dir="$(mktemp -d "${DEPLOY_DIR}/.install-staging.XXXXXX")" + trap 'rm -rf "${temp_dir:-}"' EXIT + + log_warn "Installing the rolling tip channel without a release manifest." + curl -fsSL "${RAW_BASE_URL}/main/deployment/compose.production.yml" -o "${temp_dir}/compose.production.yml" + curl -fsSL "${RAW_BASE_URL}/main/deployment/compose.postgres.yml" -o "${temp_dir}/compose.postgres.yml" + mv "${temp_dir}/compose.production.yml" "${DEPLOY_DIR}/compose.production.yml" + mv "${temp_dir}/compose.postgres.yml" "${DEPLOY_DIR}/compose.postgres.yml" + TECHULUS_CLOUD_VERSION="tip" + TECHULUS_CLOUD_WEB_IMAGE="ghcr.io/techulus/cloud/web:tip" + TECHULUS_CLOUD_REGISTRY_IMAGE="ghcr.io/techulus/cloud/registry:tip" + TECHULUS_CLOUD_UPDATER_IMAGE="ghcr.io/techulus/cloud/updater:tip" + + rm -rf "$temp_dir" + trap - EXIT + log_success "Rolling Compose files downloaded to ${DEPLOY_DIR}" + return + fi - log_success "Compose files downloaded to ${DEPLOY_DIR}" + ensure_jq + if [[ -z "$target_version" ]]; then + target_version="$(curl -fsSL "$GITHUB_LATEST_RELEASE_URL" | jq -er '.tag_name')" + fi + if [[ ! "$target_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + log_error "Invalid TECHULUS_CLOUD_VERSION: ${target_version}" + exit 1 + fi + + TECHULUS_CLOUD_VERSION="$target_version" + + local manifest_path commit + local production_checksum postgres_checksum + local web_digest registry_digest updater_digest + temp_dir="$(mktemp -d "${DEPLOY_DIR}/.install-staging.XXXXXX")" + trap 'rm -rf "${temp_dir:-}"' EXIT + manifest_path="${temp_dir}/release-manifest.json" + + log_info "Resolving release ${target_version}..." + curl -fsSL "${RELEASE_BASE_URL}/${target_version}/release-manifest.json" -o "$manifest_path" + if ! jq -e --arg version "$target_version" ' + .version == $version and + (.commit | test("^[0-9a-f]{40}$")) and + ([.composeFiles["deployment/compose.production.yml"], .composeFiles["deployment/compose.postgres.yml"]] | all(test("^[0-9a-f]{64}$"))) and + ([.images.web, .images.registry, .images.updater] | all(test("^sha256:[0-9a-f]{64}$"))) + ' "$manifest_path" >/dev/null; then + log_error "Release manifest is missing required or valid fields" + exit 1 + fi + + commit="$(jq -r '.commit' "$manifest_path")" + production_checksum="$(jq -r '.composeFiles["deployment/compose.production.yml"]' "$manifest_path")" + postgres_checksum="$(jq -r '.composeFiles["deployment/compose.postgres.yml"]' "$manifest_path")" + web_digest="$(jq -r '.images.web' "$manifest_path")" + registry_digest="$(jq -r '.images.registry' "$manifest_path")" + updater_digest="$(jq -r '.images.updater' "$manifest_path")" + + curl -fsSL "${RAW_BASE_URL}/${commit}/deployment/compose.production.yml" -o "${temp_dir}/compose.production.yml" + curl -fsSL "${RAW_BASE_URL}/${commit}/deployment/compose.postgres.yml" -o "${temp_dir}/compose.postgres.yml" + echo "${production_checksum} ${temp_dir}/compose.production.yml" | sha256sum -c - >/dev/null + echo "${postgres_checksum} ${temp_dir}/compose.postgres.yml" | sha256sum -c - >/dev/null + + mv "${temp_dir}/compose.production.yml" "${DEPLOY_DIR}/compose.production.yml" + mv "${temp_dir}/compose.postgres.yml" "${DEPLOY_DIR}/compose.postgres.yml" + TECHULUS_CLOUD_WEB_IMAGE="ghcr.io/techulus/cloud/web@${web_digest}" + TECHULUS_CLOUD_REGISTRY_IMAGE="ghcr.io/techulus/cloud/registry@${registry_digest}" + TECHULUS_CLOUD_UPDATER_IMAGE="ghcr.io/techulus/cloud/updater@${updater_digest}" + + rm -rf "$temp_dir" + trap - EXIT + + log_success "Verified ${target_version} Compose files downloaded to ${DEPLOY_DIR}" } prompt_value() { @@ -369,6 +467,7 @@ AWS_REGION=${AWS_REGION}" configure_from_file() { local src_file="$1" + local configured_compose_file log_header "Configuration (from file)" if [[ ! -f "$src_file" ]]; then @@ -376,14 +475,49 @@ configure_from_file() { exit 1 fi - cp "$src_file" "${DEPLOY_DIR}/.env" - log_success "Configuration loaded from ${src_file}" - - if grep -q "^COMPOSE_FILE=" "${DEPLOY_DIR}/.env"; then - COMPOSE_FILE="$(grep "^COMPOSE_FILE=" "${DEPLOY_DIR}/.env" | cut -d'=' -f2)" - else - COMPOSE_FILE="compose.production.yml" + configured_compose_file="$(grep -E '^COMPOSE_FILE=' "$src_file" | tail -1 | cut -d= -f2- || true)" + COMPOSE_FILE="${configured_compose_file:-compose.production.yml}" + if [[ "$COMPOSE_FILE" != "compose.production.yml" && "$COMPOSE_FILE" != "compose.postgres.yml" ]]; then + log_error "Unsupported COMPOSE_FILE: ${COMPOSE_FILE}" + exit 1 fi + + local temp_path + temp_path="$(mktemp "${DEPLOY_DIR}/.env.tmp.XXXXXX")" + + awk \ + -v version="$TECHULUS_CLOUD_VERSION" \ + -v web_image="$TECHULUS_CLOUD_WEB_IMAGE" \ + -v registry_image="$TECHULUS_CLOUD_REGISTRY_IMAGE" \ + -v updater_image="$TECHULUS_CLOUD_UPDATER_IMAGE" ' + /^TECHULUS_CLOUD_VERSION=/ { + if (!version_written++) print "TECHULUS_CLOUD_VERSION=" version + next + } + /^TECHULUS_CLOUD_WEB_IMAGE=/ { + if (!web_written++) print "TECHULUS_CLOUD_WEB_IMAGE=" web_image + next + } + /^TECHULUS_CLOUD_REGISTRY_IMAGE=/ { + if (!registry_written++) print "TECHULUS_CLOUD_REGISTRY_IMAGE=" registry_image + next + } + /^TECHULUS_CLOUD_UPDATER_IMAGE=/ { + if (!updater_written++) print "TECHULUS_CLOUD_UPDATER_IMAGE=" updater_image + next + } + { print } + END { + if (!version_written) print "TECHULUS_CLOUD_VERSION=" version + if (!web_written) print "TECHULUS_CLOUD_WEB_IMAGE=" web_image + if (!registry_written) print "TECHULUS_CLOUD_REGISTRY_IMAGE=" registry_image + if (!updater_written) print "TECHULUS_CLOUD_UPDATER_IMAGE=" updater_image + } + ' "$src_file" > "$temp_path" + chmod 600 "$temp_path" + mv "$temp_path" "${DEPLOY_DIR}/.env" + + log_success "Configuration loaded from ${src_file}" } write_env_file() { @@ -418,7 +552,10 @@ CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} WEB_REPLICAS=1 ALLOW_SIGNUP=true -TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip} +TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION} +TECHULUS_CLOUD_WEB_IMAGE=${TECHULUS_CLOUD_WEB_IMAGE} +TECHULUS_CLOUD_REGISTRY_IMAGE=${TECHULUS_CLOUD_REGISTRY_IMAGE} +TECHULUS_CLOUD_UPDATER_IMAGE=${TECHULUS_CLOUD_UPDATER_IMAGE} COMPOSE_FILE=${COMPOSE_FILE} EOF @@ -502,4 +639,6 @@ main() { build_and_start } -main +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/docs/installation.mdx b/docs/installation.mdx index 817aa41d..35a43e86 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -27,6 +27,11 @@ curl -fsSL https://raw.githubusercontent.com/techulus/cloud/main/deployment/inst The script detects your OS, installs Docker, walks you through DNS and environment configuration, and starts all services. +By default, the installer deploys the latest GitHub release and verifies its +Compose files against the release manifest. Set `TECHULUS_CLOUD_VERSION=tip` +before running the script only when you explicitly want the unverified rolling +channel. + ## Manual Setup Clone the repository and configure your environment: From 1d3f1bccfaaaa176d3514dc2aeef0bebe8d76b82 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 09:20:28 +0000 Subject: [PATCH 7/8] Remove tip from control-plane deployments Amp-Thread-ID: https://ampcode.com/threads/T-019fd59c-389b-7371-a65f-cf0c68fb7ebf Co-authored-by: Arjun Komath --- deployment/.env.example | 4 ++-- deployment/compose.postgres.yml | 12 ++++++------ deployment/compose.production.yml | 12 ++++++------ deployment/install.sh | 20 -------------------- docs/installation.mdx | 14 ++++---------- 5 files changed, 18 insertions(+), 44 deletions(-) diff --git a/deployment/.env.example b/deployment/.env.example index 368c67f7..24b926a3 100644 --- a/deployment/.env.example +++ b/deployment/.env.example @@ -43,10 +43,10 @@ INNGEST_EVENT_KEY=xxx # Use compose.production.yml for external PostgreSQL. COMPOSE_FILE=compose.production.yml # Omit TECHULUS_CLOUD_VERSION to install GitHub's latest release. -# Set it to a specific vX.Y.Z release or to tip for the rolling channel. +# Set it to a specific vX.Y.Z release to install that release. # TECHULUS_CLOUD_VERSION=vX.Y.Z # The installer and updater populate immutable release image references. -# When omitted, Compose falls back to TECHULUS_CLOUD_VERSION. +# When these image references are omitted, Compose uses TECHULUS_CLOUD_VERSION. # TECHULUS_CLOUD_WEB_IMAGE=ghcr.io/techulus/cloud/web@sha256:... # TECHULUS_CLOUD_REGISTRY_IMAGE=ghcr.io/techulus/cloud/registry@sha256:... # TECHULUS_CLOUD_UPDATER_IMAGE=ghcr.io/techulus/cloud/updater@sha256:... diff --git a/deployment/compose.postgres.yml b/deployment/compose.postgres.yml index 5aa6b35d..010167e5 100644 --- a/deployment/compose.postgres.yml +++ b/deployment/compose.postgres.yml @@ -70,11 +70,11 @@ services: restart: unless-stopped migrate: - image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}}" + image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required}}" env_file: - ./.env environment: - - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip} + - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required} - DATABASE_URL=${DATABASE_URL} - BETTER_AUTH_URL=https://${ROOT_DOMAIN} - APP_URL=https://${ROOT_DOMAIN} @@ -96,12 +96,12 @@ services: restart: on-failure web: - image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}}" + image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required}}" scale: ${WEB_REPLICAS:-1} env_file: - ./.env environment: - - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip} + - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required} - DATABASE_URL=${DATABASE_URL} - BETTER_AUTH_URL=https://${ROOT_DOMAIN} - APP_URL=https://${ROOT_DOMAIN} @@ -147,7 +147,7 @@ services: restart: unless-stopped control-plane-updater: - image: "${TECHULUS_CLOUD_UPDATER_IMAGE:-ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:-tip}}" + image: "${TECHULUS_CLOUD_UPDATER_IMAGE:-ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required}}" environment: - DEPLOY_DIR=/opt/techulus-cloud - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} @@ -163,7 +163,7 @@ services: restart: unless-stopped registry: - image: "${TECHULUS_CLOUD_REGISTRY_IMAGE:-ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:-tip}}" + image: "${TECHULUS_CLOUD_REGISTRY_IMAGE:-ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required}}" env_file: - ./.env volumes: diff --git a/deployment/compose.production.yml b/deployment/compose.production.yml index 54d3a496..746bc1b9 100644 --- a/deployment/compose.production.yml +++ b/deployment/compose.production.yml @@ -52,11 +52,11 @@ services: restart: unless-stopped migrate: - image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}}" + image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required}}" env_file: - ./.env environment: - - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip} + - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required} - DATABASE_URL=${DATABASE_URL} - BETTER_AUTH_URL=https://${ROOT_DOMAIN} - APP_URL=https://${ROOT_DOMAIN} @@ -75,12 +75,12 @@ services: restart: on-failure web: - image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}}" + image: "${TECHULUS_CLOUD_WEB_IMAGE:-ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required}}" scale: ${WEB_REPLICAS:-1} env_file: - ./.env environment: - - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip} + - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required} - DATABASE_URL=${DATABASE_URL} - BETTER_AUTH_URL=https://${ROOT_DOMAIN} - APP_URL=https://${ROOT_DOMAIN} @@ -126,7 +126,7 @@ services: restart: unless-stopped control-plane-updater: - image: "${TECHULUS_CLOUD_UPDATER_IMAGE:-ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:-tip}}" + image: "${TECHULUS_CLOUD_UPDATER_IMAGE:-ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required}}" environment: - DEPLOY_DIR=/opt/techulus-cloud - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} @@ -142,7 +142,7 @@ services: restart: unless-stopped registry: - image: "${TECHULUS_CLOUD_REGISTRY_IMAGE:-ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:-tip}}" + image: "${TECHULUS_CLOUD_REGISTRY_IMAGE:-ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:?TECHULUS_CLOUD_VERSION is required}}" env_file: - ./.env volumes: diff --git a/deployment/install.sh b/deployment/install.sh index a12db9a8..6e2602d5 100755 --- a/deployment/install.sh +++ b/deployment/install.sh @@ -266,26 +266,6 @@ download_compose_files() { local target_version temp_dir target_version="$(requested_version)" - if [[ "$target_version" == "tip" ]]; then - temp_dir="$(mktemp -d "${DEPLOY_DIR}/.install-staging.XXXXXX")" - trap 'rm -rf "${temp_dir:-}"' EXIT - - log_warn "Installing the rolling tip channel without a release manifest." - curl -fsSL "${RAW_BASE_URL}/main/deployment/compose.production.yml" -o "${temp_dir}/compose.production.yml" - curl -fsSL "${RAW_BASE_URL}/main/deployment/compose.postgres.yml" -o "${temp_dir}/compose.postgres.yml" - mv "${temp_dir}/compose.production.yml" "${DEPLOY_DIR}/compose.production.yml" - mv "${temp_dir}/compose.postgres.yml" "${DEPLOY_DIR}/compose.postgres.yml" - TECHULUS_CLOUD_VERSION="tip" - TECHULUS_CLOUD_WEB_IMAGE="ghcr.io/techulus/cloud/web:tip" - TECHULUS_CLOUD_REGISTRY_IMAGE="ghcr.io/techulus/cloud/registry:tip" - TECHULUS_CLOUD_UPDATER_IMAGE="ghcr.io/techulus/cloud/updater:tip" - - rm -rf "$temp_dir" - trap - EXIT - log_success "Rolling Compose files downloaded to ${DEPLOY_DIR}" - return - fi - ensure_jq if [[ -z "$target_version" ]]; then target_version="$(curl -fsSL "$GITHUB_LATEST_RELEASE_URL" | jq -er '.tag_name')" diff --git a/docs/installation.mdx b/docs/installation.mdx index 35a43e86..bbcfb618 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -28,9 +28,8 @@ curl -fsSL https://raw.githubusercontent.com/techulus/cloud/main/deployment/inst The script detects your OS, installs Docker, walks you through DNS and environment configuration, and starts all services. By default, the installer deploys the latest GitHub release and verifies its -Compose files against the release manifest. Set `TECHULUS_CLOUD_VERSION=tip` -before running the script only when you explicitly want the unverified rolling -channel. +Compose files against the release manifest. Set `TECHULUS_CLOUD_VERSION` before +running the script only when you need a specific release. ## Manual Setup @@ -57,9 +56,6 @@ Set `TECHULUS_CLOUD_VERSION` to the release tag you are installing: TECHULUS_CLOUD_VERSION=vX.Y.Z ``` -Use `tip` only for rolling installs. Rolling installs do not show release -update prompts. - Then start the stack: ```bash @@ -247,7 +243,7 @@ Back up both PostgreSQL and continued access to the KMS key. Deleting the KMS ke | Variable | Description | | --- | --- | | `COMPOSE_FILE` | Compose file used by self-updates. Use `compose.production.yml` for external PostgreSQL or `compose.postgres.yml` for bundled PostgreSQL. | -| `TECHULUS_CLOUD_VERSION` | Installed release tag. Use `tip` only for rolling installs without release prompts. | +| `TECHULUS_CLOUD_VERSION` | Installed release tag. The installer defaults to the latest GitHub release. | | `CONTROL_PLANE_UPDATER_TOKEN` | Random token used by the web app to call the internal updater service. Generate with `openssl rand -hex 32`. | ### Victoria Logs @@ -351,6 +347,4 @@ docker compose -f compose.production.yml down --remove-orphans docker compose -f compose.production.yml up -d --pull always --remove-orphans ``` -Use versioned or digest-pinned image references for production updates when -possible. Mutable tags such as `latest` and `tip` are convenient, but they can -move between pulls. +Use versioned or digest-pinned image references for production updates. From aefdc367565b50e9264b6b59aeb6a8deb26675a8 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 09:27:40 +0000 Subject: [PATCH 8/8] Document service crons Amp-Thread-ID: https://ampcode.com/threads/T-019fd56a-3372-7029-89df-e069c0fe68e4 Co-authored-by: Arjun Komath --- docs/api/public-api.mdx | 16 ++++++++++++---- docs/infrastructure/logging.mdx | 8 ++++++-- docs/services/configuration.mdx | 28 ++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/api/public-api.mdx b/docs/api/public-api.mdx index 35a0cd3d..90461020 100644 --- a/docs/api/public-api.mdx +++ b/docs/api/public-api.mdx @@ -141,7 +141,7 @@ Configuration and revision responses never include secret names, values, or ciph `POST /configuration/plan` accepts the same complete desired configuration as `PUT /configuration`. It performs no writes and returns the authoritative `target`, `action`, `currentVersion`, `desiredVersion`, and structured `changes` (`field`, `from`, and `to`). Service IDs on these flat service routes are authorized installation-globally rather than being scoped by a project ID in the URL. -`PUT /configuration` is atomic and replaces the complete managed configuration. Send the `currentVersion` returned by the plan as one quoted strong ETag (for example, `If-Match: "sha256:…"`). Missing, unquoted, multiple, weak, or otherwise invalid headers are rejected; a service change after planning returns `409 CONFIGURATION_PLAN_STALE`. A successful response returns the authoritative target and the structured change set that was applied. The request must contain exactly `name`, `source`, `hostname`, `ports`, `placement`, `healthCheck`, `startCommand`, and `resources`. Omitted or unknown fields are rejected. `hostname` must be concrete and non-null. Use `null` to clear nullable fields, including `resources`. +`PUT /configuration` is atomic and replaces the complete managed configuration. Send the `currentVersion` returned by the plan as one quoted strong ETag (for example, `If-Match: "sha256:…"`). Missing, unquoted, multiple, weak, or otherwise invalid headers are rejected; a service change after planning returns `409 CONFIGURATION_PLAN_STALE`. A successful response returns the authoritative target and the structured change set that was applied. The request must contain exactly `name`, `source`, `hostname`, `ports`, `placement`, `healthCheck`, `startCommand`, `resources`, and `crons`. Omitted or unknown fields are rejected. `hostname` must be concrete and non-null. Use `null` to clear nullable fields, including `resources`. Replacing the final public HTTP port with a domain automatically disables serverless in the mutable service configuration. The plan reports this side effect as a `serverless.enabled` change before apply. You do not send serverless settings in the replacement request. @@ -177,11 +177,19 @@ Replacing the final public HTTP port with a domain automatically disables server "resources": { "cpuCores": 1, "memoryMb": 512 - } + }, + "crons": [ + { + "path": "/api/cron/digest", + "schedule": "0 5 * * *" + } + ] } ``` -The `tc apply` command sends this complete replacement. A linked `techulus.yml` stores only `target.serviceId`; desired service configuration remains under `service`. Explicit CLI targeting uses `--service `. During interactive `tc link`, a project with exactly one environment selects it automatically; zero environments is an error and multiple environments prompt for a choice. +`crons` is the complete desired list of UTC service cron definitions. Each item contains exactly `path` and `schedule`. Paths must be unique, origin-relative paths without a query string or fragment. Send an empty list to remove all cron definitions. `current.crons` returns definitions only; runtime status remains outside the configuration fingerprint. Cron-only changes take effect without creating a deployment revision. See [Service crons](/services/configuration#service-crons) for request and secret behavior. + +The `tc apply` command sends this complete replacement. A linked `techulus.yml` stores its target identity under `target.serviceId` and desired configuration under `service`. `tc link` writes current cron definitions into the manifest, and `tc apply` treats that manifest list as authoritative. Explicit CLI targeting uses `--service `. During interactive `tc link`, a project with exactly one environment selects it automatically; zero environments is an error and multiple environments prompt for a choice. The API supports these source variants: @@ -341,4 +349,4 @@ The CLI uses the same endpoints documented above: | `tc metrics` | Query service metrics | | `tc revisions` | List the redacted revision changelog | -`tc link` stores only the selected `target.serviceId` in `techulus.yml`. Relinking a manifest to a different service requires removing `target.serviceId` first. `tc apply` always displays the server-generated plan and prompts before writing; use `tc apply --yes` for noninteractive automation. Image and GitHub services use the same `tc link`, `tc apply`, `tc deploy`, and inspection commands. +`tc link` stores the selected `target.serviceId` and current managed configuration, including cron definitions, in `techulus.yml`. Relinking a manifest to a different service requires removing `target.serviceId` first. `tc apply` always displays the server-generated plan and prompts before writing; use `tc apply --yes` for noninteractive automation. Image and GitHub services use the same `tc link`, `tc apply`, `tc deploy`, and inspection commands. diff --git a/docs/infrastructure/logging.mdx b/docs/infrastructure/logging.mdx index 0f060c3c..abb36618 100644 --- a/docs/infrastructure/logging.mdx +++ b/docs/infrastructure/logging.mdx @@ -3,7 +3,7 @@ title: "Logging" description: "Centralized log aggregation with Victoria Logs." --- -Techulus Cloud uses [Victoria Logs](https://docs.victoriametrics.com/victorialogs/) for centralized log aggregation. All log types — container output, HTTP access logs, build logs, and agent system logs — are collected and searchable from the web UI. +Techulus Cloud uses [Victoria Logs](https://docs.victoriametrics.com/victorialogs/) for centralized log aggregation. Container output, HTTP access logs, service cron history, build logs, and agent system logs are collected and searchable from the web UI. ## Log Types @@ -11,6 +11,7 @@ Techulus Cloud uses [Victoria Logs](https://docs.victoriametrics.com/victorialog | --- | --- | --- | | `container` | Container stdout/stderr | `deployment_id`, `service_id`, `server_id`, `stream` | | `http` | Traefik access logs (proxy nodes) | `service_id`, `host`, `method`, `path`, `status`, `duration_ms`, `client_ip` | +| `cron` | Scheduled service HTTP requests | `service_id`, `cron_id`, `path`, `result`, `status`, `duration_ms` | | `build` | BuildKit image builds | `build_id`, `service_id`, `project_id` | | `agent` | Agent process output | `server_id`, `level` | @@ -20,10 +21,11 @@ Each agent ships logs directly to Victoria Logs over HTTP using the JSON Lines f - **Container logs** are streamed from running containers, batched in groups of 1000, and flushed every 5 seconds. Log positions are tracked per container to prevent duplicates. - **HTTP logs** are tailed from Traefik's access log file on proxy nodes, batched in groups of 500. +- **Cron logs** are written by the control plane after each scheduled request with its result, HTTP status, and duration. They never include `CRON_SECRET`, the base URL, or response bodies. - **Build logs** are captured during image builds and streamed in real time. - **Agent logs** intercept the agent's own stdout/stderr with automatic log level detection. -All log batches retry up to 3 times with exponential backoff on failure. +Agent-originated log batches retry up to 3 times with exponential backoff on failure. Cron history writes are best-effort and do not retry the cron request when log ingestion fails. ## Configuration @@ -42,3 +44,5 @@ The control plane exposes logs at `https://logs.` with basic auth. Logs are accessible from the web UI for each service, deployment, build, and server. The control plane queries Victoria Logs using LogSQL with filters for `service_id`, `deployment_id`, `server_id`, and time ranges. Searches run against Victoria Logs rather than only the entries currently loaded in the browser. Continuous service, request, and server log views default to the last 24 hours and support 1-hour, 6-hour, 24-hour, and 7-day ranges. These query ranges do not change the separate `VL_RETENTION` storage setting. + +Cron history follows `VL_RETENTION`. The latest cron result remains visible in the read-only **Crons** section of [service configuration](/services/configuration#service-crons). diff --git a/docs/services/configuration.mdx b/docs/services/configuration.mdx index d16fed89..f01be417 100644 --- a/docs/services/configuration.mdx +++ b/docs/services/configuration.mdx @@ -22,6 +22,34 @@ Each secret has a key and an encrypted value, scoped to a single service. You ca Secrets are passed as environment variables to the container when it starts. +## Service crons + +Service crons send scheduled HTTP `GET` requests to your application. Define them under `service.crons` in `techulus.yml`: + +```yaml +service: + crons: + - path: /api/cron/digest + schedule: "0 5 * * *" + - path: /api/cron/cleanup + schedule: "0 * * * *" +``` + +Each path must be unique, start with `/`, and omit a query string or fragment. Schedules use five-field cron expressions in UTC. You can configure up to 100 crons per service. + +Add these service secrets from the web UI: + +| Secret | Required | Description | +| --- | --- | --- | +| `CRON_BASE_URL` | Yes | Public HTTP or HTTPS origin joined with each configured path | +| `CRON_SECRET` | No | Sent as `Authorization: Bearer `; requires an HTTPS base URL | + +The control plane only connects to public destinations. It blocks private and reserved addresses, does not follow redirects, and gives each request a 10-second deadline. Cron requests do not retry. If the control plane misses multiple intervals, it sends only the latest due occurrence instead of backfilling every missed run. + +Missing or invalid configuration produces a skipped run. Redirects and non-2xx responses produce failed runs. The read-only **Crons** section in service configuration shows the latest result. Full history is available in [service logs](/infrastructure/logging) and follows the configured log retention period. + +`tc apply` treats `service.crons` as the complete desired list. Removing the field or setting it to an empty list removes all cron definitions. Cron-only changes take effect without creating a deployment revision. + ## Start Command Override the container's default entrypoint by setting a custom start command. This is useful when deploying from pre-built images that need different startup behavior.