github.com/Redstoneguy129/cli@v0.0.0-20230211220159-15dca4e91917/internal/utils/misc.go (about) 1 package utils 2 3 import ( 4 "archive/zip" 5 "bytes" 6 "context" 7 "crypto/md5" 8 "embed" 9 "errors" 10 "fmt" 11 "io" 12 "io/fs" 13 "net/http" 14 "os" 15 "os/exec" 16 "path/filepath" 17 "regexp" 18 "runtime" 19 "strings" 20 "time" 21 22 "github.com/Redstoneguy129/cli/internal/utils/credentials" 23 "github.com/spf13/afero" 24 ) 25 26 const ( 27 Pg13Image = "supabase/postgres:13.3.0" 28 Pg14Image = "supabase/postgres:14.1.0.89" 29 Pg15Image = "supabase/postgres:15.1.0.33" 30 // Append to ServiceImages when adding new dependencies below 31 KongImage = "library/kong:2.8.1" 32 InbucketImage = "inbucket/inbucket:3.0.3" 33 PostgrestImage = "postgrest/postgrest:v10.1.1.20221215" 34 DifferImage = "supabase/pgadmin-schema-diff:cli-0.0.5" 35 MigraImage = "djrobstep/migra:3.0.1621480950" 36 PgmetaImage = "supabase/postgres-meta:v0.60.7" 37 StudioImage = "supabase/studio:20230127-6bfd87b" 38 DenoRelayImage = "supabase/deno-relay:v1.5.0" 39 ImageProxyImage = "darthsim/imgproxy:v3.8.0" 40 EdgeRuntimeImage = "supabase/edge-runtime:v1.0.9" 41 // Update initial schemas in internal/utils/templates/initial_schemas when 42 // updating any one of these. 43 GotrueImage = "supabase/gotrue:v2.40.1" 44 RealtimeImage = "supabase/realtime:v2.4.0" 45 StorageImage = "supabase/storage-api:v0.26.1" 46 // Should be kept in-sync with DenoRelayImage 47 DenoVersion = "1.28.0" 48 ) 49 50 var ServiceImages = []string{ 51 GotrueImage, 52 RealtimeImage, 53 StorageImage, 54 ImageProxyImage, 55 KongImage, 56 InbucketImage, 57 PostgrestImage, 58 DifferImage, 59 MigraImage, 60 PgmetaImage, 61 StudioImage, 62 DenoRelayImage, 63 } 64 65 func ShortContainerImageName(imageName string) string { 66 matches := ImageNamePattern.FindStringSubmatch(imageName) 67 if len(matches) < 2 { 68 return imageName 69 } 70 return matches[1] 71 } 72 73 const ( 74 // https://dba.stackexchange.com/a/11895 75 // Args: dbname 76 TerminateDbSqlFmt = ` 77 SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '%[1]s'; 78 -- Wait for WAL sender to drop replication slot. 79 DO 'BEGIN WHILE ( 80 SELECT COUNT(*) FROM pg_replication_slots WHERE database = ''%[1]s'' 81 ) > 0 LOOP END LOOP; END';` 82 AnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" 83 ServiceRoleKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU" 84 JWTSecret = "super-secret-jwt-token-with-at-least-32-characters-long" 85 AccessTokenKey = "access-token" 86 ) 87 88 var ( 89 CmdSuggestion string 90 91 // pg_dumpall --globals-only --no-role-passwords --dbname $DB_URL \ 92 // | sed '/^CREATE ROLE postgres;/d' \ 93 // | sed '/^ALTER ROLE postgres WITH /d' \ 94 // | sed "/^ALTER ROLE .* WITH .* LOGIN /s/;$/ PASSWORD 'postgres';/" 95 //go:embed templates/globals.sql 96 GlobalsSql string 97 98 //go:embed denos/* 99 denoEmbedDir embed.FS 100 101 AccessTokenPattern = regexp.MustCompile(`^sbp_[a-f0-9]{40}$`) 102 ProjectRefPattern = regexp.MustCompile(`^[a-z]{20}$`) 103 PostgresUrlPattern = regexp.MustCompile(`^postgres(?:ql)?:\/\/postgres:(.*)@(.+)\/postgres$`) 104 MigrateFilePattern = regexp.MustCompile(`^([0-9]+)_.*\.sql$`) 105 BranchNamePattern = regexp.MustCompile(`[[:word:]-]+`) 106 FuncSlugPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) 107 ImageNamePattern = regexp.MustCompile(`\/(.*):`) 108 109 // These schemas are ignored from schema diffs 110 SystemSchemas = []string{ 111 "information_schema", 112 "pg_catalog", 113 "pg_toast", 114 // "pg_temp_1", 115 // "pg_toast_temp_1", 116 // Owned by extensions 117 "cron", 118 "graphql", 119 "graphql_public", 120 "net", 121 "pgsodium", 122 "pgsodium_masks", 123 "vault", 124 } 125 InternalSchemas = append([]string{ 126 "auth", 127 "extensions", 128 "pgbouncer", 129 "realtime", 130 "_realtime", 131 "storage", 132 "supabase_functions", 133 "supabase_migrations", 134 }, SystemSchemas...) 135 136 SupabaseDirPath = "supabase" 137 ConfigPath = filepath.Join(SupabaseDirPath, "config.toml") 138 ProjectRefPath = filepath.Join(SupabaseDirPath, ".temp", "project-ref") 139 RemoteDbPath = filepath.Join(SupabaseDirPath, ".temp", "remote-db-url") 140 CurrBranchPath = filepath.Join(SupabaseDirPath, ".branches", "_current_branch") 141 MigrationsDir = filepath.Join(SupabaseDirPath, "migrations") 142 FunctionsDir = filepath.Join(SupabaseDirPath, "functions") 143 FallbackImportMapPath = filepath.Join(FunctionsDir, "import_map.json") 144 DbTestsDir = filepath.Join(SupabaseDirPath, "tests") 145 SeedDataPath = filepath.Join(SupabaseDirPath, "seed.sql") 146 ) 147 148 // Used by unit tests 149 var ( 150 DenoPathOverride string 151 ) 152 153 func GetCurrentTimestamp() string { 154 // Magic number: https://stackoverflow.com/q/45160822. 155 return time.Now().UTC().Format("20060102150405") 156 } 157 158 func GetCurrentBranchFS(fsys afero.Fs) (string, error) { 159 branch, err := afero.ReadFile(fsys, CurrBranchPath) 160 if err != nil { 161 return "", err 162 } 163 164 return string(branch), nil 165 } 166 167 // TODO: Make all errors use this. 168 func NewError(s string) error { 169 // Ask runtime.Callers for up to 5 PCs, excluding runtime.Callers and NewError. 170 pc := make([]uintptr, 5) 171 n := runtime.Callers(2, pc) 172 173 pc = pc[:n] // pass only valid pcs to runtime.CallersFrames 174 frames := runtime.CallersFrames(pc) 175 176 // Loop to get frames. 177 // A fixed number of PCs can expand to an indefinite number of Frames. 178 for { 179 frame, more := frames.Next() 180 181 // Process this frame. 182 // 183 // We're only interested in the stack trace in this repo. 184 if strings.HasPrefix(frame.Function, "github.com/Redstoneguy129/cli/internal") { 185 s += fmt.Sprintf("\n in %s:%d", frame.Function, frame.Line) 186 } 187 188 // Check whether there are more frames to process after this one. 189 if !more { 190 break 191 } 192 } 193 194 return errors.New(s) 195 } 196 197 func AssertSupabaseDbIsRunning() error { 198 if _, err := Docker.ContainerInspect(context.Background(), DbId); err != nil { 199 return errors.New(Aqua("supabase start") + " is not running.") 200 } 201 202 return nil 203 } 204 205 func GetGitRoot(fsys afero.Fs) (*string, error) { 206 origWd, err := os.Getwd() 207 if err != nil { 208 return nil, err 209 } 210 211 for { 212 _, err := afero.ReadDir(fsys, ".git") 213 214 if err == nil { 215 gitRoot, err := os.Getwd() 216 if err != nil { 217 return nil, err 218 } 219 220 if err := os.Chdir(origWd); err != nil { 221 return nil, err 222 } 223 224 return &gitRoot, nil 225 } 226 227 if cwd, err := os.Getwd(); err != nil { 228 return nil, err 229 } else if isRootDirectory(cwd) { 230 return nil, nil 231 } 232 233 if err := os.Chdir(".."); err != nil { 234 return nil, err 235 } 236 } 237 } 238 239 // If the `os.Getwd()` is within a supabase project, this will return 240 // the root of the given project as the current working directory. 241 // Otherwise, the `os.Getwd()` is kept as is. 242 func GetProjectRoot(fsys afero.Fs) (string, error) { 243 origWd, err := os.Getwd() 244 for cwd := origWd; err == nil; cwd = filepath.Dir(cwd) { 245 path := filepath.Join(cwd, ConfigPath) 246 // Treat all errors as file not exists 247 if isSupaProj, _ := afero.Exists(fsys, path); isSupaProj { 248 return cwd, nil 249 } 250 if isRootDirectory(cwd) { 251 break 252 } 253 } 254 return origWd, err 255 } 256 257 func IsBranchNameReserved(branch string) bool { 258 switch branch { 259 case "_current_branch", "main", "postgres", "template0", "template1": 260 return true 261 default: 262 return false 263 } 264 } 265 266 func MkdirIfNotExist(path string) error { 267 return MkdirIfNotExistFS(afero.NewOsFs(), path) 268 } 269 270 func MkdirIfNotExistFS(fsys afero.Fs, path string) error { 271 if err := fsys.MkdirAll(path, 0755); err != nil && !errors.Is(err, os.ErrExist) { 272 return err 273 } 274 275 return nil 276 } 277 278 func AssertSupabaseCliIsSetUpFS(fsys afero.Fs) error { 279 if _, err := fsys.Stat(ConfigPath); errors.Is(err, os.ErrNotExist) { 280 return errors.New("Cannot find " + Bold(ConfigPath) + " in the current directory. Have you set up the project with " + Aqua("supabase init") + "?") 281 } else if err != nil { 282 return err 283 } 284 285 return nil 286 } 287 288 func AssertIsLinkedFS(fsys afero.Fs) error { 289 if _, err := fsys.Stat(ProjectRefPath); errors.Is(err, os.ErrNotExist) { 290 return errors.New("Cannot find project ref. Have you run " + Aqua("supabase link") + "?") 291 } else if err != nil { 292 return err 293 } 294 295 return nil 296 } 297 298 func LoadProjectRef(fsys afero.Fs) (string, error) { 299 projectRefBytes, err := afero.ReadFile(fsys, ProjectRefPath) 300 if err != nil { 301 return "", errors.New("Cannot find project ref. Have you run " + Aqua("supabase link") + "?") 302 } 303 projectRef := string(bytes.TrimSpace(projectRefBytes)) 304 if !ProjectRefPattern.MatchString(projectRef) { 305 return "", errors.New("Invalid project ref format. Must be like `abcdefghijklmnopqrst`.") 306 } 307 return projectRef, nil 308 } 309 310 func GetDenoPath() (string, error) { 311 if len(DenoPathOverride) > 0 { 312 return DenoPathOverride, nil 313 } 314 home, err := os.UserHomeDir() 315 if err != nil { 316 return "", err 317 } 318 denoBinName := "deno" 319 if runtime.GOOS == "windows" { 320 denoBinName = "deno.exe" 321 } 322 denoPath := filepath.Join(home, ".supabase", denoBinName) 323 return denoPath, nil 324 } 325 326 func InstallOrUpgradeDeno(ctx context.Context, fsys afero.Fs) error { 327 denoPath, err := GetDenoPath() 328 if err != nil { 329 return err 330 } 331 332 if _, err := fsys.Stat(denoPath); err == nil { 333 // Upgrade Deno. 334 cmd := exec.CommandContext(ctx, denoPath, "upgrade", "--version", DenoVersion) 335 cmd.Stderr = os.Stderr 336 cmd.Stdout = os.Stdout 337 return cmd.Run() 338 } else if !errors.Is(err, os.ErrNotExist) { 339 return err 340 } 341 342 // Install Deno. 343 if err := MkdirIfNotExistFS(fsys, filepath.Dir(denoPath)); err != nil { 344 return err 345 } 346 347 // 1. Determine OS triple 348 var assetFilename string 349 assetRepo := "denoland/deno" 350 { 351 if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" { 352 assetFilename = "deno-x86_64-apple-darwin.zip" 353 } else if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { 354 assetFilename = "deno-aarch64-apple-darwin.zip" 355 } else if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { 356 assetFilename = "deno-x86_64-unknown-linux-gnu.zip" 357 } else if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" { 358 // TODO: version pin to official release once available https://github.com/denoland/deno/issues/1846 359 assetRepo = "LukeChannings/deno-arm64" 360 assetFilename = "deno-linux-arm64.zip" 361 } else if runtime.GOOS == "windows" && runtime.GOARCH == "amd64" { 362 assetFilename = "deno-x86_64-pc-windows-msvc.zip" 363 } else { 364 return errors.New("Platform " + runtime.GOOS + "/" + runtime.GOARCH + " is currently unsupported for Functions.") 365 } 366 } 367 368 // 2. Download & install Deno binary. 369 { 370 assetUrl := fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", assetRepo, DenoVersion, assetFilename) 371 req, err := http.NewRequestWithContext(ctx, "GET", assetUrl, nil) 372 if err != nil { 373 return err 374 } 375 resp, err := http.DefaultClient.Do(req) 376 if err != nil { 377 return err 378 } 379 defer resp.Body.Close() 380 381 if resp.StatusCode != 200 { 382 return errors.New("Failed installing Deno binary.") 383 } 384 385 body, err := io.ReadAll(resp.Body) 386 if err != nil { 387 return err 388 } 389 390 r, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) 391 // There should be only 1 file: the deno binary 392 if len(r.File) != 1 { 393 return err 394 } 395 denoContents, err := r.File[0].Open() 396 if err != nil { 397 return err 398 } 399 defer denoContents.Close() 400 401 denoBytes, err := io.ReadAll(denoContents) 402 if err != nil { 403 return err 404 } 405 406 if err := afero.WriteFile(fsys, denoPath, denoBytes, 0755); err != nil { 407 return err 408 } 409 } 410 411 return nil 412 } 413 414 func isScriptModified(fsys afero.Fs, destPath string, src []byte) (bool, error) { 415 dest, err := afero.ReadFile(fsys, destPath) 416 if err != nil { 417 if errors.Is(err, fs.ErrNotExist) { 418 return true, nil 419 } 420 return false, err 421 } 422 423 // compare the md5 checksum of src bytes with user's copy. 424 // if the checksums doesn't match, script is modified. 425 return md5.Sum(dest) != md5.Sum(src), nil 426 } 427 428 type DenoScriptDir struct { 429 ExtractPath string 430 BuildPath string 431 } 432 433 // Copy Deno scripts needed for function deploy and downloads, returning a DenoScriptDir struct or an error. 434 func CopyDenoScripts(ctx context.Context, fsys afero.Fs) (*DenoScriptDir, error) { 435 denoPath, err := GetDenoPath() 436 if err != nil { 437 return nil, err 438 } 439 440 denoDirPath := filepath.Dir(denoPath) 441 scriptDirPath := filepath.Join(denoDirPath, "denos") 442 443 // make the script directory if not exist 444 if err := MkdirIfNotExistFS(fsys, scriptDirPath); err != nil { 445 return nil, err 446 } 447 448 // copy embed files to script directory 449 err = fs.WalkDir(denoEmbedDir, "denos", func(path string, d fs.DirEntry, err error) error { 450 if err != nil { 451 return err 452 } 453 454 // skip copying the directory 455 if d.IsDir() { 456 return nil 457 } 458 459 destPath := filepath.Join(denoDirPath, path) 460 461 contents, err := fs.ReadFile(denoEmbedDir, path) 462 if err != nil { 463 return err 464 } 465 466 // check if the script should be copied 467 modified, err := isScriptModified(fsys, destPath, contents) 468 if err != nil { 469 return err 470 } 471 if !modified { 472 return nil 473 } 474 475 if err := afero.WriteFile(fsys, filepath.Join(denoDirPath, path), contents, 0666); err != nil { 476 return err 477 } 478 479 return nil 480 }) 481 482 if err != nil { 483 return nil, err 484 } 485 486 sd := DenoScriptDir{ 487 ExtractPath: filepath.Join(scriptDirPath, "extract.ts"), 488 BuildPath: filepath.Join(scriptDirPath, "build.ts"), 489 } 490 491 return &sd, nil 492 } 493 494 func LoadAccessToken() (string, error) { 495 return LoadAccessTokenFS(afero.NewOsFs()) 496 } 497 498 func LoadAccessTokenFS(fsys afero.Fs) (string, error) { 499 // Env takes precedence 500 if accessToken := os.Getenv("SUPABASE_ACCESS_TOKEN"); accessToken != "" { 501 if !AccessTokenPattern.MatchString(accessToken) { 502 return "", errors.New("Invalid access token format. Must be like `sbp_0102...1920`.") 503 } 504 return accessToken, nil 505 } 506 // Load from native credentials store 507 if token, err := credentials.Get(AccessTokenKey); err == nil { 508 return token, nil 509 } 510 // Fallback to home directory 511 home, err := os.UserHomeDir() 512 if err != nil { 513 return "", err 514 } 515 accessTokenPath := filepath.Join(home, ".supabase", AccessTokenKey) 516 accessToken, err := afero.ReadFile(fsys, accessTokenPath) 517 if errors.Is(err, os.ErrNotExist) || string(accessToken) == "" { 518 return "", errors.New("Access token not provided. Supply an access token by running " + Aqua("supabase login") + " or setting the SUPABASE_ACCESS_TOKEN environment variable.") 519 } else if err != nil { 520 return "", err 521 } 522 return string(accessToken), nil 523 } 524 525 func ValidateFunctionSlug(slug string) error { 526 if !FuncSlugPattern.MatchString(slug) { 527 return errors.New("Invalid Function name. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (^[A-Za-z][A-Za-z0-9_-]*$)") 528 } 529 530 return nil 531 }