package activator import ( "errors" "fmt" "os" "path/filepath" "strings" "github.com/carlosframework/platform/internal/instancefs" "github.com/carlosframework/platform/internal/instancekey" "github.com/carlosframework/platform/internal/registry" ) // An import and the instance key (instancekey.go; merge review S1). // // The platform mints CARLOS_INSTANCE_KEY into the instance's own // .env at its first launch, create-only, and a key there outranks one // the app's configuration sets (instancekey.Effective). An import's first // launch is its verify request, so an import that is then aborted has left // a key behind. Left in place it would outlive the rollback: a second // import of the same host, whose owner configured the old process's key // before verifying, would still be handed the first import's key, and the // values the old process sealed would not open. // // So the swap records what the own env file held before the import // (SeedEnvBefore, kept in the import journal), and the fenced rollback // retires what the import created and nothing else (RetireSeedKey). // // Removing a minted key is within the key's rules. Create-only means a key // is never replaced under data sealed with it. Here the data goes too: the // rollback removes the import's database and deletes its replica, under the // same fence. .env is an installation artifact, retired with its // installation, as a move retires it on the box it leaves. // seedEnvDir is the directory holding the own env file of the nested row // whose database is dbPath, and the file's name. func seedEnvDir(dbPath, host string) (dir, name string, err error) { dir, _, err = splitDBPath(dbPath) if err != nil { return "", "", err } if host == "" || host != filepath.Base(host) { return "", "", fmt.Errorf("activator: %q is not a host name", host) } return dir, host + ".env", nil } // SeedEnvBefore reports what the own env file of the nested row at dbPath // holds now, as one of registry's EnvBefore values: no file, a file with no // instance key, or a file that carries one. The swap calls it before the // import's row exists, so before anything can launch the import. The file // is read through instancefs, as every nested read of it is. A file that // cannot be read (a link, a FIFO, over the limit) is an error: the caller // cannot tell, and does not swap. func SeedEnvBefore(dbPath, host string) (string, error) { dir, name, err := seedEnvDir(dbPath, host) if err != nil { return "", err } adminTokenMu.Lock() defer adminTokenMu.Unlock() raw, exists, err := readSeedEnv(dir, name) switch { case err != nil: return "", err case !exists: return registry.EnvBeforeAbsent, nil case parseEnvBytes(raw)[InstanceKeyVar] != "": return registry.EnvBeforeKey, nil } return registry.EnvBeforeNoKey, nil } // readSeedEnv reads dir/name beneath dir. An instance directory that is not // there holds no file. func readSeedEnv(dir, name string) (raw []byte, exists bool, err error) { if _, err := os.Lstat(dir); errors.Is(err, os.ErrNotExist) { return nil, false, nil } raw, err = instancefs.ReadBeneathLimit(dir, name, ownEnvReadLimit) if errors.Is(err, os.ErrNotExist) { return nil, false, nil } if err != nil { return nil, false, fmt.Errorf("read %s: %w", filepath.Join(dir, name), err) } return raw, true, nil } // RetireSeedKey retires the instance key an import created, for the nested // row at dbPath, by what the swap recorded (before): // // - EnvBeforeAbsent: there was no env file, so the one there now is the // import's whole. It is removed, and with it the adoption record beside // it, which belongs to the same installation. // - EnvBeforeNoKey: the file was there with no key. Its CARLOS_INSTANCE_KEY // lines are the import's and are removed; every other line stays, byte // for byte. // - EnvBeforeKey: the key was there before the import. Nothing is touched. // - "": the journal row predates the record. Nothing is known, and nothing // is touched. // // The caller holds the host fenced and stopped (the import rollback's step // 3), so no launch reads the file meanwhile; adminTokenMu keeps the // startup backfill's append out. It is idempotent: a second run finds // nothing of the import's left. func RetireSeedKey(dbPath, host, before string) error { switch before { case registry.EnvBeforeAbsent, registry.EnvBeforeNoKey: case registry.EnvBeforeKey, "": return nil default: return fmt.Errorf("activator: %s: %q is not a recorded env state", host, before) } dir, name, err := seedEnvDir(dbPath, host) if err != nil { return err } adminTokenMu.Lock() defer adminTokenMu.Unlock() if _, err := os.Lstat(dir); errors.Is(err, os.ErrNotExist) { return nil } if before == registry.EnvBeforeAbsent { for _, n := range []string{name, host + instancekey.AdoptionSuffix} { if err := instancefs.RemoveAllBeneath(dir, n); err != nil { return fmt.Errorf("retire %s: %w", filepath.Join(dir, n), err) } } return syncDir(dir) } raw, exists, err := readSeedEnv(dir, name) if err != nil || !exists { return err } kept, removed := withoutInstanceKey(raw) if !removed { return nil } if err := instancefs.WriteAtomic(dir, name, kept, 0o600); err != nil { return fmt.Errorf("retire the instance key in %s: %w", filepath.Join(dir, name), err) } return syncDir(dir) } // withoutInstanceKey is raw with every line that sets CARLOS_INSTANCE_KEY // removed, by instancekey.ParseEnv's reading of a line; the other lines are // kept exactly. removed is whether any line went. func withoutInstanceKey(raw []byte) (kept []byte, removed bool) { lines := strings.Split(string(raw), "\n") out := lines[:0] for _, line := range lines { t := strings.TrimSpace(line) if t != "" && !strings.HasPrefix(t, "#") { if k, _, ok := strings.Cut(t, "="); ok && strings.TrimSpace(k) == InstanceKeyVar { removed = true continue } } out = append(out, line) } return []byte(strings.Join(out, "\n")), removed }