package gitops import ( "context" "errors" "strings" "github.com/alexei/tinyforge/internal/staticsite" ) // maxConfigBytes caps the .tinyforge.yml fetch. The file is tiny; the cap // stops a hostile/misconfigured repo from streaming an unbounded body. const maxConfigBytes = 64 * 1024 // Status is the outcome of a Fetch. All outcomes are values (not errors) so a // caller always has something to show: an absent file or a provider blip is a // normal state, not a 500. type Status string const ( StatusOK Status = "ok" // file present and parsed StatusNoFile Status = "no_file" // GitOps enabled, no file at path StatusFetchFailed Status = "fetch_failed" // transport/auth/5xx error StatusInvalid Status = "invalid" // file present but failed to parse ) // RepoRef is the minimal repo locator Fetch needs. The caller (API layer) // extracts these from the workload's source_config and decrypts the token — // this package stays decoupled from the store and source plugins. type RepoRef struct { Provider string // "gitea" | "github" | "gitlab" | "" (autodetect from BaseURL) BaseURL string Owner string Repo string Branch string Token string // decrypted; "" for public repos Path string // repo-relative file path; defaults to .tinyforge.yml } // Result carries everything the API/UI needs about a fetch. Message is a // human-safe, token-redacted detail for non-ok statuses. type Result struct { Status Status Raw []byte Spec Spec CommitSHA string Message string } // Fetch reads the .tinyforge.yml from a workload's repo and parses it. Every // failure mode is encoded in Result.Status (never a returned error), with any // detail token-redacted in Result.Message. A missing file is StatusNoFile, not // a failure — never a reason to block or clear config. func Fetch(ctx context.Context, ref RepoRef) Result { provider, err := staticsite.NewGitProvider(staticsite.ProviderType(ref.Provider), ref.BaseURL, ref.Token) if err != nil { return Result{Status: StatusFetchFailed, Message: redact(err, ref.Token)} } // Best-effort: the SHA lets the UI show which ref the file came from. A // failure here doesn't sink the fetch — the file read below is what matters. sha, _ := provider.GetLatestCommitSHA(ctx, ref.Owner, ref.Repo, ref.Branch) path := ref.Path if path == "" { path = ".tinyforge.yml" } data, err := provider.DownloadFile(ctx, ref.Owner, ref.Repo, ref.Branch, path, maxConfigBytes) if err != nil { if errors.Is(err, staticsite.ErrFileNotFound) { return Result{Status: StatusNoFile, CommitSHA: sha} } return Result{Status: StatusFetchFailed, CommitSHA: sha, Message: redact(err, ref.Token)} } spec, err := ParseSpec(data) if err != nil { // Parse errors describe YAML structure (line/col), not the token. return Result{Status: StatusInvalid, Raw: data, CommitSHA: sha, Message: err.Error()} } return Result{Status: StatusOK, Raw: data, Spec: spec, CommitSHA: sha} } // redact strips the access token from an error message so a fetch failure can // be surfaced or persisted without leaking the credential (mirrors the // sanitizeError convention in the static/dockerfile sources). func redact(err error, token string) string { if err == nil { return "" } msg := err.Error() if token != "" { msg = strings.ReplaceAll(msg, token, "[redacted]") } return msg }