From 31598283bc503c348084ceb6abc36133c9c36553 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 07:58:06 +0000 Subject: [PATCH 1/4] 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 a161028946359e06b5dae7f235d340c8c3ab7e07 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 08:28:51 +0000 Subject: [PATCH 2/4] 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 f67ff163de8da3832c98e6a64fb9ead86e4fd9d9 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 6 Aug 2026 08:46:02 +0000 Subject: [PATCH 3/4] 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 4/4] 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.