package stacks import ( "encoding/json" "fmt" "os/exec" "regexp" "strings" ) // ExtractedCreds is the result of reading an app's auto-generated first-login credential from a file // inside its container (driven by Metadata.InitialCreds). Available is false when the spec is present // but the credential can't be read right now (container down, file gone) — the UI then hides the card // rather than showing an error. type ExtractedCreds struct { Available bool `json:"available"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` Note string `json:"note,omitempty"` } // ReadInitialCredentials reads and parses the configured first-login credential file from the deployed // stack's container. Returns (nil, nil) when the stack has no InitialCreds spec (nothing to do); a // non-Available result when the spec exists but the file can't be read yet. The file path comes from // the catalog (trusted) and is passed to `docker exec … cat` as a single arg (no shell), so a path // with spaces/odd chars can't break out. func (m *Manager) ReadInitialCredentials(stackName string) (*ExtractedCreds, error) { stack, ok := m.GetStack(stackName) if !ok { return nil, fmt.Errorf("stack %q not found", stackName) } spec := stack.Meta.InitialCreds if spec == nil || spec.File == "" { return nil, nil // no spec — nothing to extract } // Resolve which container to read from: explicit override, else the stack's main container. container := spec.Container if container == "" { container = findProbeContainer(stack.Name, stack.Containers) } if container == "" { if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] initial-creds %s: no running container to read from", stackName) } return &ExtractedCreds{Available: false}, nil } out, err := exec.Command("docker", "exec", container, "cat", spec.File).Output() if err != nil { // File missing / container not exec-able yet — expected during early boot or after the // customer deletes the file. Not an error worth surfacing; hide the card. if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] initial-creds %s: cat %s in %s failed: %v", stackName, spec.File, container, err) } return &ExtractedCreds{Available: false}, nil } content := string(out) if strings.TrimSpace(content) == "" { return &ExtractedCreds{Available: false}, nil } creds, perr := parseInitialCreds(spec, content) if perr != nil { m.logger.Printf("[WARN] [stacks] initial-creds %s: parse %s (%s) failed: %v", stackName, spec.File, spec.Format, perr) return &ExtractedCreds{Available: false}, nil } if creds.Password == "" { // A spec that yields no password is useless — log so a bad catalog spec is noticed. m.logger.Printf("[WARN] [stacks] initial-creds %s: no password extracted from %s (format=%s)", stackName, spec.File, spec.Format) return &ExtractedCreds{Available: false}, nil } creds.Note = spec.Note creds.Available = true if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] initial-creds %s: extracted username=%q password=", stackName, creds.Username, len(creds.Password)) } return creds, nil } // parseInitialCreds is the pure parsing core (unit-testable without docker): given the spec and the // raw file content, it pulls out username/password per the declared format. func parseInitialCreds(spec *InitialCredentials, content string) (*ExtractedCreds, error) { switch strings.ToLower(spec.Format) { case "json": var obj map[string]any if err := json.Unmarshal([]byte(content), &obj); err != nil { return nil, fmt.Errorf("invalid JSON: %w", err) } pwKey := spec.PasswordKey if pwKey == "" { pwKey = "password" } return &ExtractedCreds{ Username: jsonStr(obj, spec.UsernameKey), Password: jsonStr(obj, pwKey), }, nil case "regex": if spec.PasswordPattern == "" { return nil, fmt.Errorf("regex format requires password_pattern") } pw, err := firstSubmatch(spec.PasswordPattern, content) if err != nil { return nil, err } user := "" if spec.UsernamePattern != "" { user, _ = firstSubmatch(spec.UsernamePattern, content) } return &ExtractedCreds{Username: user, Password: pw}, nil case "plain", "": // Whole file (trimmed) is the password; no username. return &ExtractedCreds{Password: strings.TrimSpace(content)}, nil default: return nil, fmt.Errorf("unknown format %q (want json|regex|plain)", spec.Format) } } // jsonStr fetches a string value from a decoded JSON object; empty key or non-string → "". func jsonStr(obj map[string]any, key string) string { if key == "" { return "" } if v, ok := obj[key]; ok { if s, ok := v.(string); ok { return s } } return "" } // firstSubmatch compiles pattern and returns its first capture group against content. func firstSubmatch(pattern, content string) (string, error) { re, err := regexp.Compile(pattern) if err != nil { return "", fmt.Errorf("bad pattern %q: %w", pattern, err) } mm := re.FindStringSubmatch(content) if len(mm) < 2 { return "", nil // no match / no capture group — treated as "not found" } return strings.TrimSpace(mm[1]), nil }