feat: static sites feature with Gitea/GitHub/GitLab support and Deno backend
Deploy static content from Git repository folders with optional server-side
API endpoints. Supports Gitea/Forgejo/Gogs, GitHub, and GitLab with provider
autodetection.
- New Sites entity with CRUD, encrypted secrets, and manual/push/tag sync triggers
- Pluggable GitProvider interface with three implementations
- Deno container mode: auto-generates router from API_{method}_{name} exports
- Static container mode: nginx serving files with optional markdown rendering
- Wizard UI with provider selector, repo picker, branch/folder tree pickers
- Deploy pipeline builds fresh image, starts container, configures NPM proxy
- Stop/Start buttons, force redeploy on manual trigger
- Periodic health checker detects crashed containers
- Proxy route existence check during auto-sync
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
package deno
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// RouteEntry represents a parsed API endpoint from a TypeScript file.
|
||||
type RouteEntry struct {
|
||||
Method string // GET, POST, PUT, DELETE, PATCH
|
||||
Path string // e.g., "/api/weather/current"
|
||||
ImportPath string // relative import path, e.g., "./api/weather.ts"
|
||||
FunctionName string // original export name, e.g., "API_get_current"
|
||||
}
|
||||
|
||||
// validMethods lists the HTTP methods recognized by the convention.
|
||||
var validMethods = map[string]bool{
|
||||
"get": true, "post": true, "put": true, "delete": true, "patch": true,
|
||||
}
|
||||
|
||||
// apiExportPattern matches "export async function API_..." or "export function API_..."
|
||||
var apiExportPattern = regexp.MustCompile(`^export\s+(?:async\s+)?function\s+(API_\w+)`)
|
||||
|
||||
// ScanRoutes scans all .ts files in the api/ subdirectory for API_ prefixed exports
|
||||
// and returns a list of RouteEntry for each discovered endpoint.
|
||||
func ScanRoutes(apiDir string) ([]RouteEntry, error) {
|
||||
var routes []RouteEntry
|
||||
|
||||
err := filepath.Walk(apiDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".ts" && ext != ".js" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Derive the base route from file path relative to apiDir.
|
||||
relPath, err := filepath.Rel(apiDir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rel path: %w", err)
|
||||
}
|
||||
// Convert "weather.ts" → "weather", "sub/weather.ts" → "sub/weather"
|
||||
baseName := strings.TrimSuffix(relPath, filepath.Ext(relPath))
|
||||
baseName = filepath.ToSlash(baseName)
|
||||
baseRoute := "/api/" + baseName
|
||||
|
||||
// Import path relative to the generated router.
|
||||
importPath := "./api/" + filepath.ToSlash(relPath)
|
||||
|
||||
// Scan file for API_ exports.
|
||||
fileRoutes, err := scanFileExports(path, baseRoute, importPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan %s: %w", relPath, err)
|
||||
}
|
||||
routes = append(routes, fileRoutes...)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return routes, err
|
||||
}
|
||||
|
||||
// scanFileExports reads a TypeScript file and extracts API_ prefixed exports.
|
||||
func scanFileExports(filePath, baseRoute, importPath string) ([]RouteEntry, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var routes []RouteEntry
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
matches := apiExportPattern.FindStringSubmatch(line)
|
||||
if len(matches) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
funcName := matches[1] // e.g., "API_get_current"
|
||||
entry, ok := parseAPIFunctionName(funcName, baseRoute, importPath)
|
||||
if ok {
|
||||
routes = append(routes, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return routes, scanner.Err()
|
||||
}
|
||||
|
||||
// parseAPIFunctionName parses "API_get_current" into a RouteEntry.
|
||||
// Convention: API_{method} → handles base route; API_{method}_{path} → handles sub-route.
|
||||
func parseAPIFunctionName(funcName, baseRoute, importPath string) (RouteEntry, bool) {
|
||||
// Strip "API_" prefix.
|
||||
rest := strings.TrimPrefix(funcName, "API_")
|
||||
if rest == "" {
|
||||
return RouteEntry{}, false
|
||||
}
|
||||
|
||||
// Split on first "_" to extract method.
|
||||
parts := strings.SplitN(rest, "_", 2)
|
||||
method := strings.ToLower(parts[0])
|
||||
if !validMethods[method] {
|
||||
return RouteEntry{}, false
|
||||
}
|
||||
|
||||
path := baseRoute
|
||||
if len(parts) == 2 && parts[1] != "" {
|
||||
path = baseRoute + "/" + parts[1]
|
||||
}
|
||||
|
||||
return RouteEntry{
|
||||
Method: strings.ToUpper(method),
|
||||
Path: path,
|
||||
ImportPath: importPath,
|
||||
FunctionName: funcName,
|
||||
}, true
|
||||
}
|
||||
|
||||
// routerTemplate is the Deno router entrypoint template.
|
||||
var routerTemplate = template.Must(template.New("router").Parse(`// Auto-generated by Docker Watcher — do not edit manually.
|
||||
import { serveDir } from "https://deno.land/std/http/file_server.ts";
|
||||
|
||||
{{- range .Imports}}
|
||||
import { {{.FunctionName}} as {{.Alias}} } from "{{.Path}}";
|
||||
{{- end}}
|
||||
|
||||
const routes: Array<{ method: string; path: string; handler: (req: Request) => Promise<Response> | Response }> = [
|
||||
{{- range .Routes}}
|
||||
{ method: "{{.Method}}", path: "{{.Path}}", handler: {{.Alias}} },
|
||||
{{- end}}
|
||||
];
|
||||
|
||||
Deno.serve({ port: 8000 }, async (req: Request): Promise<Response> => {
|
||||
const url = new URL(req.url);
|
||||
const method = req.method.toUpperCase();
|
||||
|
||||
// Match API routes.
|
||||
for (const route of routes) {
|
||||
if (route.method === method && url.pathname === route.path) {
|
||||
try {
|
||||
return await route.handler(req);
|
||||
} catch (e) {
|
||||
console.error("Handler error:", e);
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serve static files from /public.
|
||||
return serveDir(req, { fsRoot: "/app/public", quiet: true });
|
||||
});
|
||||
`))
|
||||
|
||||
// ImportEntry represents a single aliased import.
|
||||
type ImportEntry struct {
|
||||
FunctionName string // original name, e.g., "API_get"
|
||||
Alias string // unique alias, e.g., "time_API_get"
|
||||
Path string // import path, e.g., "./api/time.ts"
|
||||
}
|
||||
|
||||
// routerData holds the data for the router template.
|
||||
type routerData struct {
|
||||
Imports []ImportEntry
|
||||
Routes []routeWithAlias
|
||||
}
|
||||
|
||||
// routeWithAlias is a route entry using the aliased handler name.
|
||||
type routeWithAlias struct {
|
||||
Method string
|
||||
Path string
|
||||
Alias string
|
||||
}
|
||||
|
||||
// GenerateRouter generates the Deno router TypeScript source from route entries.
|
||||
func GenerateRouter(routes []RouteEntry) (string, error) {
|
||||
var imports []ImportEntry
|
||||
var aliasedRoutes []routeWithAlias
|
||||
|
||||
for _, r := range routes {
|
||||
// Derive a unique alias from the file base name + function name.
|
||||
// e.g., "./api/echo.ts" + "API_get" → "echo_API_get"
|
||||
baseName := filepath.Base(r.ImportPath)
|
||||
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
||||
alias := baseName + "_" + r.FunctionName
|
||||
|
||||
imports = append(imports, ImportEntry{
|
||||
FunctionName: r.FunctionName,
|
||||
Alias: alias,
|
||||
Path: r.ImportPath,
|
||||
})
|
||||
|
||||
aliasedRoutes = append(aliasedRoutes, routeWithAlias{
|
||||
Method: r.Method,
|
||||
Path: r.Path,
|
||||
Alias: alias,
|
||||
})
|
||||
}
|
||||
|
||||
data := routerData{Imports: imports, Routes: aliasedRoutes}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := routerTemplate.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("execute router template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// GenerateDockerfile generates the Dockerfile for the Deno container.
|
||||
func GenerateDockerfile() string {
|
||||
return `FROM denoland/deno:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy static files.
|
||||
COPY public/ /app/public/
|
||||
|
||||
# Copy API source files and generated router.
|
||||
COPY api/ /app/api/
|
||||
COPY router.ts /app/router.ts
|
||||
|
||||
# Cache dependencies.
|
||||
RUN deno install --entrypoint router.ts
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-env", "router.ts"]
|
||||
`
|
||||
}
|
||||
|
||||
// GenerateStaticDockerfile generates a minimal nginx Dockerfile for pure static sites.
|
||||
func GenerateStaticDockerfile() string {
|
||||
return `FROM nginx:alpine
|
||||
|
||||
COPY . /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package staticsite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// giteaTreeEntry represents a single entry in a Gitea git tree response.
|
||||
type giteaTreeEntry struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // "blob" or "tree"
|
||||
SHA string `json:"sha"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// giteaTreeResponse represents the Gitea git tree API response.
|
||||
type giteaTreeResponse struct {
|
||||
SHA string `json:"sha"`
|
||||
Entries []giteaTreeEntry `json:"tree"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// giteaBranch represents a branch from the Gitea API.
|
||||
type giteaBranch struct {
|
||||
Name string `json:"name"`
|
||||
Commit struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
|
||||
// giteaRef represents a git reference from the Gitea API.
|
||||
type giteaRef struct {
|
||||
Ref string `json:"ref"`
|
||||
Object struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"object"`
|
||||
}
|
||||
|
||||
// GiteaContentFetcher downloads folder contents from a Gitea repository.
|
||||
type GiteaContentFetcher struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewGiteaContentFetcher creates a new content fetcher.
|
||||
// token may be empty for public repositories.
|
||||
func NewGiteaContentFetcher(baseURL, token string) *GiteaContentFetcher {
|
||||
return &GiteaContentFetcher{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the provider identifier.
|
||||
func (f *GiteaContentFetcher) Name() string { return "gitea" }
|
||||
|
||||
// ListRepos returns repositories accessible with the current token.
|
||||
func (f *GiteaContentFetcher) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) {
|
||||
var allRepos []RepoInfo
|
||||
page := 1
|
||||
limit := 50
|
||||
|
||||
for {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/search?limit=%d&page=%d", f.baseURL, limit, page)
|
||||
if query != "" {
|
||||
url += "&q=" + query
|
||||
}
|
||||
if f.token != "" {
|
||||
// When authenticated, include private repos.
|
||||
url += "&private=true"
|
||||
}
|
||||
|
||||
body, err := f.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list repos: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []struct {
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
// Gitea search wraps in {"data": [...]}, but some versions return a flat array.
|
||||
var flat []struct {
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
if err2 := json.Unmarshal(body, &flat); err2 != nil {
|
||||
return nil, fmt.Errorf("decode repos: %w", err)
|
||||
}
|
||||
for _, r := range flat {
|
||||
allRepos = append(allRepos, RepoInfo{
|
||||
Owner: r.Owner.Login,
|
||||
Name: r.Name,
|
||||
FullName: r.FullName,
|
||||
Description: r.Description,
|
||||
Private: r.Private,
|
||||
HTMLURL: r.HTMLURL,
|
||||
})
|
||||
}
|
||||
if len(flat) < limit {
|
||||
break
|
||||
}
|
||||
page++
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range result.Data {
|
||||
allRepos = append(allRepos, RepoInfo{
|
||||
Owner: r.Owner.Login,
|
||||
Name: r.Name,
|
||||
FullName: r.FullName,
|
||||
Description: r.Description,
|
||||
Private: r.Private,
|
||||
HTMLURL: r.HTMLURL,
|
||||
})
|
||||
}
|
||||
|
||||
if len(result.Data) < limit {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return allRepos, nil
|
||||
}
|
||||
|
||||
// ListBranches returns all branches for a repository.
|
||||
func (f *GiteaContentFetcher) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
|
||||
var allBranches []string
|
||||
page := 1
|
||||
limit := 50
|
||||
|
||||
for {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?page=%d&limit=%d",
|
||||
f.baseURL, owner, repo, page, limit)
|
||||
|
||||
body, err := f.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list branches: %w", err)
|
||||
}
|
||||
|
||||
var branches []giteaBranch
|
||||
if err := json.Unmarshal(body, &branches); err != nil {
|
||||
return nil, fmt.Errorf("decode branches: %w", err)
|
||||
}
|
||||
|
||||
for _, b := range branches {
|
||||
allBranches = append(allBranches, b.Name)
|
||||
}
|
||||
|
||||
if len(branches) < limit {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return allBranches, nil
|
||||
}
|
||||
|
||||
// GetLatestCommitSHA returns the latest commit SHA for a branch.
|
||||
func (f *GiteaContentFetcher) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches/%s",
|
||||
f.baseURL, owner, repo, branch)
|
||||
|
||||
body, err := f.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get branch info: %w", err)
|
||||
}
|
||||
|
||||
var b giteaBranch
|
||||
if err := json.Unmarshal(body, &b); err != nil {
|
||||
return "", fmt.Errorf("decode branch: %w", err)
|
||||
}
|
||||
|
||||
return b.Commit.ID, nil
|
||||
}
|
||||
|
||||
// FolderEntry represents a file or directory in the repo tree.
|
||||
type FolderEntry struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
// ListTree returns the full directory tree for a branch, useful for the folder picker.
|
||||
func (f *GiteaContentFetcher) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
|
||||
f.baseURL, owner, repo, branch)
|
||||
|
||||
body, err := f.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tree: %w", err)
|
||||
}
|
||||
|
||||
var tree giteaTreeResponse
|
||||
if err := json.Unmarshal(body, &tree); err != nil {
|
||||
return nil, fmt.Errorf("decode tree: %w", err)
|
||||
}
|
||||
|
||||
entries := make([]FolderEntry, 0, len(tree.Entries))
|
||||
for _, e := range tree.Entries {
|
||||
entries = append(entries, FolderEntry{
|
||||
Path: e.Path,
|
||||
IsDir: e.Type == "tree",
|
||||
})
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// DownloadFolder downloads all files from a specific folder path in the repo
|
||||
// to a local temporary directory. Returns the path to the temp directory.
|
||||
func (f *GiteaContentFetcher) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error {
|
||||
// Get the full tree.
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
|
||||
f.baseURL, owner, repo, branch)
|
||||
|
||||
body, err := f.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch tree: %w", err)
|
||||
}
|
||||
|
||||
var tree giteaTreeResponse
|
||||
if err := json.Unmarshal(body, &tree); err != nil {
|
||||
return fmt.Errorf("decode tree: %w", err)
|
||||
}
|
||||
|
||||
// Normalize folder path.
|
||||
folderPath = strings.TrimPrefix(folderPath, "/")
|
||||
folderPath = strings.TrimSuffix(folderPath, "/")
|
||||
prefix := folderPath + "/"
|
||||
|
||||
// Download each file in the folder.
|
||||
for _, entry := range tree.Entries {
|
||||
if entry.Type != "blob" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(entry.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
relativePath := strings.TrimPrefix(entry.Path, prefix)
|
||||
localPath := filepath.Join(destDir, filepath.FromSlash(relativePath))
|
||||
|
||||
// Create parent directories.
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create directory for %s: %w", relativePath, err)
|
||||
}
|
||||
|
||||
// Download the file.
|
||||
fileURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s",
|
||||
f.baseURL, owner, repo, entry.Path, branch)
|
||||
|
||||
if err := f.downloadFile(ctx, fileURL, localPath); err != nil {
|
||||
return fmt.Errorf("download %s: %w", relativePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection verifies that the repository is accessible.
|
||||
func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo string) error {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s", f.baseURL, owner, repo)
|
||||
_, err := f.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("test connection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// doGet performs an authenticated GET request and returns the response body.
|
||||
func (f *GiteaContentFetcher) doGet(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
if f.token != "" {
|
||||
req.Header.Set("Authorization", "token "+f.token)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// downloadFile downloads a URL to a local file path.
|
||||
func (f *GiteaContentFetcher) downloadFile(ctx context.Context, url, localPath string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
if f.token != "" {
|
||||
req.Header.Set("Authorization", "token "+f.token)
|
||||
}
|
||||
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
file, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||
return fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package staticsite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GitHubProvider implements GitProvider for GitHub repositories.
|
||||
type GitHubProvider struct {
|
||||
apiBase string // "https://api.github.com" for github.com
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewGitHubProvider creates a new GitHub provider.
|
||||
// baseURL should be "https://github.com" or a GitHub Enterprise URL.
|
||||
func NewGitHubProvider(baseURL, token string) *GitHubProvider {
|
||||
apiBase := "https://api.github.com"
|
||||
base := strings.TrimRight(baseURL, "/")
|
||||
if base != "https://github.com" && base != "http://github.com" {
|
||||
// GitHub Enterprise: API is at {base}/api/v3
|
||||
apiBase = base + "/api/v3"
|
||||
}
|
||||
|
||||
return &GitHubProvider{
|
||||
apiBase: apiBase,
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) Name() string { return "github" }
|
||||
|
||||
func (g *GitHubProvider) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) {
|
||||
var allRepos []RepoInfo
|
||||
|
||||
if query != "" {
|
||||
// Use search API.
|
||||
url := fmt.Sprintf("%s/search/repositories?q=%s&per_page=50", g.apiBase, query)
|
||||
body, err := g.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search repos: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Items []struct {
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode search: %w", err)
|
||||
}
|
||||
for _, r := range result.Items {
|
||||
allRepos = append(allRepos, RepoInfo{
|
||||
Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName,
|
||||
Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL,
|
||||
})
|
||||
}
|
||||
return allRepos, nil
|
||||
}
|
||||
|
||||
// List authenticated user's repos.
|
||||
page := 1
|
||||
for {
|
||||
url := fmt.Sprintf("%s/user/repos?per_page=100&page=%d&sort=updated", g.apiBase, page)
|
||||
body, err := g.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list repos: %w", err)
|
||||
}
|
||||
|
||||
var repos []struct {
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &repos); err != nil {
|
||||
return nil, fmt.Errorf("decode repos: %w", err)
|
||||
}
|
||||
for _, r := range repos {
|
||||
allRepos = append(allRepos, RepoInfo{
|
||||
Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName,
|
||||
Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL,
|
||||
})
|
||||
}
|
||||
if len(repos) < 100 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return allRepos, nil
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) TestConnection(ctx context.Context, owner, repo string) error {
|
||||
url := fmt.Sprintf("%s/repos/%s/%s", g.apiBase, owner, repo)
|
||||
_, err := g.doGet(ctx, url)
|
||||
return err
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
|
||||
var allBranches []string
|
||||
page := 1
|
||||
|
||||
for {
|
||||
url := fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d",
|
||||
g.apiBase, owner, repo, page)
|
||||
|
||||
body, err := g.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list branches: %w", err)
|
||||
}
|
||||
|
||||
var branches []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &branches); err != nil {
|
||||
return nil, fmt.Errorf("decode branches: %w", err)
|
||||
}
|
||||
|
||||
for _, b := range branches {
|
||||
allBranches = append(allBranches, b.Name)
|
||||
}
|
||||
|
||||
if len(branches) < 100 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return allBranches, nil
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) {
|
||||
url := fmt.Sprintf("%s/repos/%s/%s/branches/%s", g.apiBase, owner, repo, branch)
|
||||
|
||||
body, err := g.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get branch: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Commit struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("decode branch: %w", err)
|
||||
}
|
||||
|
||||
return result.Commit.SHA, nil
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) {
|
||||
url := fmt.Sprintf("%s/repos/%s/%s/git/trees/%s?recursive=1",
|
||||
g.apiBase, owner, repo, branch)
|
||||
|
||||
body, err := g.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tree: %w", err)
|
||||
}
|
||||
|
||||
var tree struct {
|
||||
Tree []struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // "blob" or "tree"
|
||||
} `json:"tree"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tree); err != nil {
|
||||
return nil, fmt.Errorf("decode tree: %w", err)
|
||||
}
|
||||
|
||||
entries := make([]FolderEntry, 0, len(tree.Tree))
|
||||
for _, e := range tree.Tree {
|
||||
entries = append(entries, FolderEntry{
|
||||
Path: e.Path,
|
||||
IsDir: e.Type == "tree",
|
||||
})
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error {
|
||||
// Get tree to find files in folder.
|
||||
entries, err := g.ListTree(ctx, owner, repo, branch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list tree: %w", err)
|
||||
}
|
||||
|
||||
folderPath = strings.TrimPrefix(folderPath, "/")
|
||||
folderPath = strings.TrimSuffix(folderPath, "/")
|
||||
prefix := folderPath + "/"
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(entry.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
relativePath := strings.TrimPrefix(entry.Path, prefix)
|
||||
localPath := filepath.Join(destDir, filepath.FromSlash(relativePath))
|
||||
|
||||
// GitHub raw content URL.
|
||||
// For github.com: https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}
|
||||
// For GHE: {baseURL}/{owner}/{repo}/raw/{branch}/{path}
|
||||
var fileURL string
|
||||
if g.apiBase == "https://api.github.com" {
|
||||
fileURL = fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s",
|
||||
owner, repo, branch, entry.Path)
|
||||
} else {
|
||||
// GHE: use API contents endpoint.
|
||||
fileURL = fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s",
|
||||
g.apiBase, owner, repo, entry.Path, branch)
|
||||
}
|
||||
|
||||
if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil {
|
||||
return fmt.Errorf("download %s: %w", relativePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) doGet(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
g.setAuth(req)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := g.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) setAuth(req *http.Request) {
|
||||
if g.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+g.token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package staticsite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GitLabProvider implements GitProvider for GitLab repositories.
|
||||
type GitLabProvider struct {
|
||||
apiBase string // e.g., "https://gitlab.com/api/v4"
|
||||
rawBase string // e.g., "https://gitlab.com"
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewGitLabProvider creates a new GitLab provider.
|
||||
// baseURL should be "https://gitlab.com" or a self-hosted GitLab URL.
|
||||
func NewGitLabProvider(baseURL, token string) *GitLabProvider {
|
||||
base := strings.TrimRight(baseURL, "/")
|
||||
return &GitLabProvider{
|
||||
apiBase: base + "/api/v4",
|
||||
rawBase: base,
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) Name() string { return "gitlab" }
|
||||
|
||||
// projectPath returns the URL-encoded project path (owner/repo → owner%2Frepo).
|
||||
func projectPath(owner, repo string) string {
|
||||
return url.PathEscape(owner + "/" + repo)
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) {
|
||||
var allRepos []RepoInfo
|
||||
page := 1
|
||||
|
||||
for {
|
||||
apiURL := fmt.Sprintf("%s/projects?membership=true&per_page=100&page=%d&order_by=last_activity_at", g.apiBase, page)
|
||||
if query != "" {
|
||||
apiURL += "&search=" + url.QueryEscape(query)
|
||||
}
|
||||
|
||||
body, err := g.doGet(ctx, apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list repos: %w", err)
|
||||
}
|
||||
|
||||
var projects []struct {
|
||||
PathWithNamespace string `json:"path_with_namespace"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Visibility string `json:"visibility"`
|
||||
WebURL string `json:"web_url"`
|
||||
Namespace struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"namespace"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &projects); err != nil {
|
||||
return nil, fmt.Errorf("decode repos: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
allRepos = append(allRepos, RepoInfo{
|
||||
Owner: p.Namespace.Path,
|
||||
Name: p.Name,
|
||||
FullName: p.PathWithNamespace,
|
||||
Description: p.Description,
|
||||
Private: p.Visibility != "public",
|
||||
HTMLURL: p.WebURL,
|
||||
})
|
||||
}
|
||||
|
||||
if len(projects) < 100 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return allRepos, nil
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) TestConnection(ctx context.Context, owner, repo string) error {
|
||||
apiURL := fmt.Sprintf("%s/projects/%s", g.apiBase, projectPath(owner, repo))
|
||||
_, err := g.doGet(ctx, apiURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
|
||||
var allBranches []string
|
||||
page := 1
|
||||
|
||||
for {
|
||||
apiURL := fmt.Sprintf("%s/projects/%s/repository/branches?per_page=100&page=%d",
|
||||
g.apiBase, projectPath(owner, repo), page)
|
||||
|
||||
body, err := g.doGet(ctx, apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list branches: %w", err)
|
||||
}
|
||||
|
||||
var branches []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &branches); err != nil {
|
||||
return nil, fmt.Errorf("decode branches: %w", err)
|
||||
}
|
||||
|
||||
for _, b := range branches {
|
||||
allBranches = append(allBranches, b.Name)
|
||||
}
|
||||
|
||||
if len(branches) < 100 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return allBranches, nil
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) {
|
||||
apiURL := fmt.Sprintf("%s/projects/%s/repository/branches/%s",
|
||||
g.apiBase, projectPath(owner, repo), url.PathEscape(branch))
|
||||
|
||||
body, err := g.doGet(ctx, apiURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get branch: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Commit struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("decode branch: %w", err)
|
||||
}
|
||||
|
||||
return result.Commit.ID, nil
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) {
|
||||
var allEntries []FolderEntry
|
||||
page := 1
|
||||
|
||||
for {
|
||||
apiURL := fmt.Sprintf("%s/projects/%s/repository/tree?ref=%s&recursive=true&per_page=100&page=%d",
|
||||
g.apiBase, projectPath(owner, repo), url.QueryEscape(branch), page)
|
||||
|
||||
body, err := g.doGet(ctx, apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tree: %w", err)
|
||||
}
|
||||
|
||||
var entries []struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // "blob" or "tree"
|
||||
}
|
||||
if err := json.Unmarshal(body, &entries); err != nil {
|
||||
return nil, fmt.Errorf("decode tree: %w", err)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
allEntries = append(allEntries, FolderEntry{
|
||||
Path: e.Path,
|
||||
IsDir: e.Type == "tree",
|
||||
})
|
||||
}
|
||||
|
||||
if len(entries) < 100 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return allEntries, nil
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error {
|
||||
entries, err := g.ListTree(ctx, owner, repo, branch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list tree: %w", err)
|
||||
}
|
||||
|
||||
folderPath = strings.TrimPrefix(folderPath, "/")
|
||||
folderPath = strings.TrimSuffix(folderPath, "/")
|
||||
prefix := folderPath + "/"
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(entry.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
relativePath := strings.TrimPrefix(entry.Path, prefix)
|
||||
localPath := filepath.Join(destDir, filepath.FromSlash(relativePath))
|
||||
|
||||
// GitLab raw file URL: {base}/{owner}/{repo}/-/raw/{branch}/{path}
|
||||
fileURL := fmt.Sprintf("%s/%s/%s/-/raw/%s/%s",
|
||||
g.rawBase, owner, repo, branch, entry.Path)
|
||||
|
||||
if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil {
|
||||
return fmt.Errorf("download %s: %w", relativePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) doGet(ctx context.Context, apiURL string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
g.setAuth(req)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := g.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) setAuth(req *http.Request) {
|
||||
if g.token != "" {
|
||||
req.Header.Set("PRIVATE-TOKEN", g.token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package staticsite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// HealthChecker periodically checks that deployed static site containers
|
||||
// are still running. If a container has crashed, it updates the site status
|
||||
// to "failed" and optionally triggers a redeploy.
|
||||
type HealthChecker struct {
|
||||
store *store.Store
|
||||
docker *docker.Client
|
||||
manager *Manager
|
||||
|
||||
cron *cron.Cron
|
||||
mu sync.Mutex
|
||||
entryID cron.EntryID
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewHealthChecker creates a new static site health checker.
|
||||
func NewHealthChecker(st *store.Store, dockerClient *docker.Client, mgr *Manager) *HealthChecker {
|
||||
return &HealthChecker{
|
||||
store: st,
|
||||
docker: dockerClient,
|
||||
manager: mgr,
|
||||
cron: cron.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the periodic health check with the given interval (e.g., "5m", "1m").
|
||||
func (h *HealthChecker) Start(interval string) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
duration, err := time.ParseDuration(interval)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse interval %q: %w", interval, err)
|
||||
}
|
||||
|
||||
if h.running {
|
||||
h.cron.Remove(h.entryID)
|
||||
}
|
||||
|
||||
spec := fmt.Sprintf("@every %s", duration)
|
||||
id, err := h.cron.AddFunc(spec, h.check)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule health check: %w", err)
|
||||
}
|
||||
|
||||
h.entryID = id
|
||||
h.running = true
|
||||
h.cron.Start()
|
||||
|
||||
slog.Info("static site health checker started", "interval", interval)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the periodic health checker.
|
||||
func (h *HealthChecker) Stop() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.running {
|
||||
h.cron.Stop()
|
||||
h.running = false
|
||||
slog.Info("static site health checker stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// check runs a single health check pass over all deployed static sites.
|
||||
func (h *HealthChecker) check() {
|
||||
sites, err := h.store.GetAllStaticSites()
|
||||
if err != nil {
|
||||
slog.Error("static site health check: failed to list sites", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for _, site := range sites {
|
||||
if site.Status != "deployed" || site.ContainerID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
running, err := h.docker.IsContainerRunning(ctx, site.ContainerID)
|
||||
if err != nil {
|
||||
// Container might have been removed externally.
|
||||
slog.Warn("static site health check: container inspect failed",
|
||||
"site", site.Name, "container", site.ContainerID[:12], "error", err)
|
||||
h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container not found")
|
||||
h.manager.publishEvent(site.ID, site.Name, "failed: container not found")
|
||||
continue
|
||||
}
|
||||
|
||||
if !running {
|
||||
slog.Warn("static site health check: container not running",
|
||||
"site", site.Name, "container", site.ContainerID[:12])
|
||||
h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container stopped unexpectedly")
|
||||
h.manager.publishEvent(site.ID, site.Name, "failed: container stopped unexpectedly")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
package staticsite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/proxy"
|
||||
"github.com/alexei/docker-watcher/internal/staticsite/deno"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
// Manager orchestrates the static site deployment pipeline.
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
docker *docker.Client
|
||||
proxyProvider proxy.Provider
|
||||
eventBus *events.Bus
|
||||
encKey [32]byte
|
||||
}
|
||||
|
||||
// NewManager creates a new static site manager.
|
||||
func NewManager(
|
||||
st *store.Store,
|
||||
dockerClient *docker.Client,
|
||||
proxyProvider proxy.Provider,
|
||||
eventBus *events.Bus,
|
||||
encKey [32]byte,
|
||||
) *Manager {
|
||||
return &Manager{
|
||||
store: st,
|
||||
docker: dockerClient,
|
||||
proxyProvider: proxyProvider,
|
||||
eventBus: eventBus,
|
||||
encKey: encKey,
|
||||
}
|
||||
}
|
||||
|
||||
// SetProxyProvider updates the proxy provider at runtime.
|
||||
func (m *Manager) SetProxyProvider(provider proxy.Provider) {
|
||||
m.proxyProvider = provider
|
||||
}
|
||||
|
||||
// Deploy fetches content from Gitea and deploys a static site container.
|
||||
// If force is true, skips the "no changes" check and always rebuilds/redeploys.
|
||||
func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
|
||||
site, err := m.store.GetStaticSiteByID(siteID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get site: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt access token if present.
|
||||
token := ""
|
||||
if site.AccessToken != "" {
|
||||
decrypted, err := crypto.Decrypt(m.encKey, site.AccessToken)
|
||||
if err != nil {
|
||||
slog.Warn("static site: failed to decrypt access token", "site", site.Name, "error", err)
|
||||
} else {
|
||||
token = decrypted
|
||||
}
|
||||
}
|
||||
|
||||
provider, err := NewGitProvider(ProviderType(site.Provider), site.GiteaURL, token)
|
||||
if err != nil {
|
||||
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create provider: %v", err))
|
||||
return fmt.Errorf("create provider: %w", err)
|
||||
}
|
||||
|
||||
// Check if there's a new commit.
|
||||
latestSHA, err := provider.GetLatestCommitSHA(ctx, site.RepoOwner, site.RepoName, site.Branch)
|
||||
if err != nil {
|
||||
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("fetch commit SHA: %v", err))
|
||||
return fmt.Errorf("get latest commit: %w", err)
|
||||
}
|
||||
|
||||
// Skip redeploy only if SHA matches, status is deployed, container is running,
|
||||
// proxy route exists, AND force is false. Manual deploys always force a full rebuild.
|
||||
if !force && latestSHA == site.LastCommitSHA && site.Status == "deployed" && site.ContainerID != "" {
|
||||
running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID)
|
||||
if !running {
|
||||
slog.Info("static site: container not running, forcing redeploy", "site", site.Name)
|
||||
} else if site.Domain != "" {
|
||||
// Also verify the proxy route still exists (it may have been deleted externally).
|
||||
proxyOK, err := m.proxyProvider.RouteExists(ctx, site.Domain)
|
||||
if err != nil {
|
||||
slog.Warn("static site: proxy check failed, forcing redeploy", "site", site.Name, "error", err)
|
||||
} else if !proxyOK {
|
||||
slog.Info("static site: proxy route missing, forcing redeploy", "site", site.Name)
|
||||
} else {
|
||||
slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to syncing.
|
||||
m.updateStatus(site.ID, "syncing", site.LastCommitSHA, "")
|
||||
m.publishEvent(site.ID, site.Name, "syncing")
|
||||
|
||||
// Create temp directory for the build context.
|
||||
buildDir, err := os.MkdirTemp("", "dw-site-"+site.Name+"-*")
|
||||
if err != nil {
|
||||
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create temp dir: %v", err))
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(buildDir)
|
||||
|
||||
// Download folder contents.
|
||||
if err := provider.DownloadFolder(ctx, site.RepoOwner, site.RepoName, site.Branch, site.FolderPath, buildDir); err != nil {
|
||||
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("download folder: %v", err))
|
||||
return fmt.Errorf("download folder: %w", err)
|
||||
}
|
||||
|
||||
// Render markdown if enabled.
|
||||
if site.RenderMarkdown {
|
||||
if err := RenderMarkdownFiles(buildDir); err != nil {
|
||||
slog.Warn("static site: markdown rendering failed", "site", site.Name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine mode: check for api/ subdirectory.
|
||||
mode := site.Mode
|
||||
apiDir := filepath.Join(buildDir, "api")
|
||||
hasAPI := false
|
||||
if info, err := os.Stat(apiDir); err == nil && info.IsDir() {
|
||||
hasAPI = true
|
||||
}
|
||||
if mode == "deno" && !hasAPI {
|
||||
// Fallback to static if no api/ folder found.
|
||||
mode = "static"
|
||||
slog.Info("static site: no api/ folder found, falling back to static mode", "site", site.Name)
|
||||
}
|
||||
|
||||
// Prepare build context based on mode.
|
||||
imageTag := fmt.Sprintf("dw-site-%s:latest", site.Name)
|
||||
contextDir, err := os.MkdirTemp("", "dw-site-build-*")
|
||||
if err != nil {
|
||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create build context: %v", err))
|
||||
return fmt.Errorf("create build context dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(contextDir)
|
||||
|
||||
if mode == "deno" {
|
||||
if err := m.prepareDenoBuild(buildDir, contextDir); err != nil {
|
||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare deno build: %v", err))
|
||||
return fmt.Errorf("prepare deno build: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := m.prepareStaticBuild(buildDir, contextDir); err != nil {
|
||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare static build: %v", err))
|
||||
return fmt.Errorf("prepare static build: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build Docker image.
|
||||
if err := m.docker.BuildImage(ctx, contextDir, imageTag); err != nil {
|
||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build image: %v", err))
|
||||
return fmt.Errorf("build image: %w", err)
|
||||
}
|
||||
|
||||
// Prepare environment variables (secrets).
|
||||
env, err := m.buildEnvVars(site.ID)
|
||||
if err != nil {
|
||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build env vars: %v", err))
|
||||
return fmt.Errorf("build env vars: %w", err)
|
||||
}
|
||||
|
||||
// Determine container port.
|
||||
containerPort := "80"
|
||||
if mode == "deno" {
|
||||
containerPort = "8000"
|
||||
}
|
||||
|
||||
// Get network settings.
|
||||
settings, err := m.store.GetSettings()
|
||||
if err != nil {
|
||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("get settings: %v", err))
|
||||
return fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
|
||||
networkName := settings.Network
|
||||
networkID, err := m.docker.EnsureNetwork(ctx, networkName)
|
||||
if err != nil {
|
||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("ensure network: %v", err))
|
||||
return fmt.Errorf("ensure network: %w", err)
|
||||
}
|
||||
|
||||
containerName := fmt.Sprintf("dw-site-%s", site.Name)
|
||||
|
||||
// Create and start new container.
|
||||
containerID, err := m.docker.CreateContainer(ctx, docker.ContainerConfig{
|
||||
Name: containerName,
|
||||
Image: imageTag,
|
||||
Env: env,
|
||||
ExposedPorts: []string{containerPort + "/tcp"},
|
||||
NetworkName: networkName,
|
||||
NetworkID: networkID,
|
||||
Labels: map[string]string{
|
||||
"docker-watcher.static-site": site.ID,
|
||||
"docker-watcher.static-site-name": site.Name,
|
||||
},
|
||||
Project: "static-site",
|
||||
Stage: site.Name,
|
||||
})
|
||||
if err != nil {
|
||||
// Container might already exist — try to remove and recreate.
|
||||
if site.ContainerID != "" {
|
||||
m.docker.StopContainer(ctx, site.ContainerID, 10)
|
||||
m.docker.RemoveContainer(ctx, site.ContainerID, true)
|
||||
}
|
||||
// Also try by name.
|
||||
m.removeContainerByName(ctx, containerName)
|
||||
|
||||
containerID, err = m.docker.CreateContainer(ctx, docker.ContainerConfig{
|
||||
Name: containerName,
|
||||
Image: imageTag,
|
||||
Env: env,
|
||||
ExposedPorts: []string{containerPort + "/tcp"},
|
||||
NetworkName: networkName,
|
||||
NetworkID: networkID,
|
||||
Labels: map[string]string{
|
||||
"docker-watcher.static-site": site.ID,
|
||||
"docker-watcher.static-site-name": site.Name,
|
||||
},
|
||||
Project: "static-site",
|
||||
Stage: site.Name,
|
||||
})
|
||||
if err != nil {
|
||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create container: %v", err))
|
||||
return fmt.Errorf("create container: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.docker.StartContainer(ctx, containerID); err != nil {
|
||||
m.docker.RemoveContainer(ctx, containerID, true)
|
||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("start container: %v", err))
|
||||
return fmt.Errorf("start container: %w", err)
|
||||
}
|
||||
|
||||
// Brief health check: wait 3 seconds and verify container is still running.
|
||||
time.Sleep(3 * time.Second)
|
||||
running, err := m.docker.IsContainerRunning(ctx, containerID)
|
||||
if err != nil || !running {
|
||||
// Grab container logs for the error message.
|
||||
logMsg := "container exited immediately after start"
|
||||
if logs, logErr := m.docker.ContainerLogs(ctx, containerID, false, "20"); logErr == nil {
|
||||
buf, _ := io.ReadAll(logs)
|
||||
logs.Close()
|
||||
if len(buf) > 0 {
|
||||
logMsg = string(buf)
|
||||
// Truncate to reasonable length.
|
||||
if len(logMsg) > 500 {
|
||||
logMsg = logMsg[:500] + "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
m.docker.RemoveContainer(ctx, containerID, true)
|
||||
m.updateStatus(site.ID, "failed", latestSHA, logMsg)
|
||||
return fmt.Errorf("container not running: %s", logMsg)
|
||||
}
|
||||
|
||||
// Determine proxy target: container name + internal port (default),
|
||||
// or server IP + host port for NPM remote mode.
|
||||
internalPort, _ := strconv.Atoi(containerPort)
|
||||
forwardHost := containerName
|
||||
forwardPort := internalPort
|
||||
|
||||
if settings.NpmRemote && settings.ProxyProvider == "npm" {
|
||||
if settings.ServerIP != "" {
|
||||
hostPort, err := m.docker.InspectContainerPort(ctx, containerID, containerPort+"/tcp")
|
||||
if err != nil {
|
||||
slog.Warn("static site: could not get host port for remote NPM", "site", site.Name, "error", err)
|
||||
} else {
|
||||
forwardHost = settings.ServerIP
|
||||
forwardPort = int(hostPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure proxy if domain is set.
|
||||
proxyRouteID := site.ProxyRouteID
|
||||
if site.Domain != "" {
|
||||
// Remove old proxy route if exists.
|
||||
if site.ProxyRouteID != "" {
|
||||
m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID)
|
||||
}
|
||||
|
||||
routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("static site: failed to configure proxy", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "error", err)
|
||||
} else {
|
||||
proxyRouteID = routeID
|
||||
slog.Info("static site: proxy configured", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "routeID", routeID)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old container if different.
|
||||
if site.ContainerID != "" && site.ContainerID != containerID {
|
||||
m.docker.StopContainer(ctx, site.ContainerID, 10)
|
||||
m.docker.RemoveContainer(ctx, site.ContainerID, true)
|
||||
}
|
||||
|
||||
// Update site status.
|
||||
if err := m.store.UpdateStaticSiteContainer(site.ID, containerID, proxyRouteID); err != nil {
|
||||
slog.Error("static site: failed to update container info", "site", site.Name, "error", err)
|
||||
}
|
||||
m.updateStatus(site.ID, "deployed", latestSHA, "")
|
||||
m.publishEvent(site.ID, site.Name, "deployed")
|
||||
|
||||
slog.Info("static site deployed", "site", site.Name, "sha", latestSHA[:8], "mode", mode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove stops and removes a static site's container and proxy route.
|
||||
func (m *Manager) Remove(ctx context.Context, siteID string) error {
|
||||
site, err := m.store.GetStaticSiteByID(siteID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get site: %w", err)
|
||||
}
|
||||
|
||||
// Remove proxy route (best effort).
|
||||
if site.ProxyRouteID != "" {
|
||||
if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil {
|
||||
slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop and remove container (best effort).
|
||||
if site.ContainerID != "" {
|
||||
m.docker.StopContainer(ctx, site.ContainerID, 10)
|
||||
if err := m.docker.RemoveContainer(ctx, site.ContainerID, true); err != nil {
|
||||
slog.Warn("static site: failed to remove container", "site", site.Name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops a running static site container and removes its proxy route.
|
||||
// The container is kept (not removed) so Start can bring it back without a full rebuild.
|
||||
func (m *Manager) Stop(ctx context.Context, siteID string) error {
|
||||
site, err := m.store.GetStaticSiteByID(siteID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get site: %w", err)
|
||||
}
|
||||
|
||||
// Remove proxy route first (best effort).
|
||||
if site.ProxyRouteID != "" {
|
||||
if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil {
|
||||
slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop container.
|
||||
if site.ContainerID != "" {
|
||||
if err := m.docker.StopContainer(ctx, site.ContainerID, 10); err != nil {
|
||||
slog.Warn("static site: failed to stop container", "site", site.Name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear proxy route ID; keep container ID.
|
||||
if err := m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, ""); err != nil {
|
||||
slog.Error("static site: failed to clear proxy route", "site", site.Name, "error", err)
|
||||
}
|
||||
m.updateStatus(site.ID, "stopped", site.LastCommitSHA, "")
|
||||
m.publishEvent(site.ID, site.Name, "stopped")
|
||||
|
||||
slog.Info("static site stopped", "site", site.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts a previously stopped static site container and reconfigures the proxy.
|
||||
// If the container no longer exists, it triggers a full redeploy.
|
||||
func (m *Manager) Start(ctx context.Context, siteID string) error {
|
||||
site, err := m.store.GetStaticSiteByID(siteID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get site: %w", err)
|
||||
}
|
||||
|
||||
// If no container exists, do a full deploy.
|
||||
if site.ContainerID == "" {
|
||||
return m.Deploy(ctx, siteID, true)
|
||||
}
|
||||
|
||||
// Try to start the existing container.
|
||||
if err := m.docker.StartContainer(ctx, site.ContainerID); err != nil {
|
||||
slog.Warn("static site: failed to start container, falling back to redeploy", "site", site.Name, "error", err)
|
||||
return m.Deploy(ctx, siteID, true)
|
||||
}
|
||||
|
||||
// Verify it's running after a brief wait.
|
||||
time.Sleep(2 * time.Second)
|
||||
running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID)
|
||||
if !running {
|
||||
return m.Deploy(ctx, siteID, true)
|
||||
}
|
||||
|
||||
// Reconfigure proxy if domain is set.
|
||||
settings, err := m.store.GetSettings()
|
||||
if err == nil && site.Domain != "" {
|
||||
containerPort := "80"
|
||||
if site.Mode == "deno" {
|
||||
containerPort = "8000"
|
||||
}
|
||||
internalPort, _ := strconv.Atoi(containerPort)
|
||||
containerName := fmt.Sprintf("dw-site-%s", site.Name)
|
||||
forwardHost := containerName
|
||||
forwardPort := internalPort
|
||||
|
||||
if settings.NpmRemote && settings.ProxyProvider == "npm" && settings.ServerIP != "" {
|
||||
if hp, err := m.docker.InspectContainerPort(ctx, site.ContainerID, containerPort+"/tcp"); err == nil {
|
||||
forwardHost = settings.ServerIP
|
||||
forwardPort = int(hp)
|
||||
}
|
||||
}
|
||||
|
||||
routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("static site: failed to reconfigure proxy on start", "site", site.Name, "error", err)
|
||||
} else {
|
||||
m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, routeID)
|
||||
}
|
||||
}
|
||||
|
||||
m.updateStatus(site.ID, "deployed", site.LastCommitSHA, "")
|
||||
m.publishEvent(site.ID, site.Name, "deployed")
|
||||
|
||||
slog.Info("static site started", "site", site.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection tests connectivity to a Git repository.
|
||||
func (m *Manager) TestConnection(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) error {
|
||||
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return provider.TestConnection(ctx, owner, repo)
|
||||
}
|
||||
|
||||
// ListBranches returns branches for a Git repository.
|
||||
func (m *Manager) ListBranches(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) ([]string, error) {
|
||||
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.ListBranches(ctx, owner, repo)
|
||||
}
|
||||
|
||||
// ListTree returns the repository tree for the folder picker.
|
||||
func (m *Manager) ListTree(ctx context.Context, providerType, baseURL, accessToken, owner, repo, branch string) ([]FolderEntry, error) {
|
||||
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.ListTree(ctx, owner, repo, branch)
|
||||
}
|
||||
|
||||
// ListRepos returns repositories from a Git server.
|
||||
func (m *Manager) ListRepos(ctx context.Context, providerType, baseURL, accessToken, query string) ([]RepoInfo, error) {
|
||||
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.ListRepos(ctx, query)
|
||||
}
|
||||
|
||||
// DetectProvider autodetects the Git provider from a URL, with API probing.
|
||||
func (m *Manager) DetectProvider(ctx context.Context, baseURL string) string {
|
||||
return string(DetectProviderWithProbe(ctx, baseURL))
|
||||
}
|
||||
|
||||
// createProvider builds a GitProvider from encrypted credentials.
|
||||
func (m *Manager) createProvider(providerType, baseURL, accessToken string) (GitProvider, error) {
|
||||
token := ""
|
||||
if accessToken != "" {
|
||||
decrypted, err := crypto.Decrypt(m.encKey, accessToken)
|
||||
if err != nil {
|
||||
token = accessToken // might be plaintext
|
||||
} else {
|
||||
token = decrypted
|
||||
}
|
||||
}
|
||||
return NewGitProvider(ProviderType(providerType), baseURL, token)
|
||||
}
|
||||
|
||||
// prepareDenoBuild sets up the build context for a Deno container.
|
||||
func (m *Manager) prepareDenoBuild(srcDir, contextDir string) error {
|
||||
// Move api/ to context.
|
||||
apiSrc := filepath.Join(srcDir, "api")
|
||||
apiDst := filepath.Join(contextDir, "api")
|
||||
if err := os.Rename(apiSrc, apiDst); err != nil {
|
||||
return fmt.Errorf("move api dir: %w", err)
|
||||
}
|
||||
|
||||
// Move remaining files to public/.
|
||||
publicDir := filepath.Join(contextDir, "public")
|
||||
if err := os.Rename(srcDir, publicDir); err != nil {
|
||||
// If rename fails (cross-device), use copy.
|
||||
if err := copyDir(srcDir, publicDir); err != nil {
|
||||
return fmt.Errorf("copy public dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan routes and generate router.
|
||||
routes, err := deno.ScanRoutes(apiDst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan routes: %w", err)
|
||||
}
|
||||
|
||||
routerSrc, err := deno.GenerateRouter(routes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate router: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(contextDir, "router.ts"), []byte(routerSrc), 0o644); err != nil {
|
||||
return fmt.Errorf("write router.ts: %w", err)
|
||||
}
|
||||
|
||||
// Generate Dockerfile.
|
||||
dockerfile := deno.GenerateDockerfile()
|
||||
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
|
||||
return fmt.Errorf("write Dockerfile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareStaticBuild sets up the build context for a static nginx container.
|
||||
func (m *Manager) prepareStaticBuild(srcDir, contextDir string) error {
|
||||
// Copy all files to context directory.
|
||||
if err := copyDir(srcDir, contextDir); err != nil {
|
||||
return fmt.Errorf("copy files: %w", err)
|
||||
}
|
||||
|
||||
// Generate Dockerfile.
|
||||
dockerfile := deno.GenerateStaticDockerfile()
|
||||
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
|
||||
return fmt.Errorf("write Dockerfile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildEnvVars decrypts secrets and builds environment variable list.
|
||||
func (m *Manager) buildEnvVars(siteID string) ([]string, error) {
|
||||
secrets, err := m.store.GetStaticSiteSecretsBySiteID(siteID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get secrets: %w", err)
|
||||
}
|
||||
|
||||
env := make([]string, 0, len(secrets))
|
||||
for _, s := range secrets {
|
||||
value := s.Value
|
||||
if s.Encrypted {
|
||||
decrypted, err := crypto.Decrypt(m.encKey, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt secret %s: %w", s.Key, err)
|
||||
}
|
||||
value = decrypted
|
||||
}
|
||||
env = append(env, s.Key+"="+value)
|
||||
}
|
||||
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// removeContainerByName removes a container by its name (best effort).
|
||||
func (m *Manager) removeContainerByName(ctx context.Context, name string) {
|
||||
containers, err := m.docker.ListContainers(ctx, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c.Name == name {
|
||||
m.docker.StopContainer(ctx, c.ID, 10)
|
||||
m.docker.RemoveContainer(ctx, c.ID, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateStatus updates the site status in the database.
|
||||
// On failure, it also publishes an event to the event log.
|
||||
func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) {
|
||||
if err := m.store.UpdateStaticSiteStatus(id, status, commitSHA, errMsg); err != nil {
|
||||
slog.Error("static site: failed to update status", "id", id, "status", status, "error", err)
|
||||
}
|
||||
|
||||
// Persist failures to event log automatically.
|
||||
if status == "failed" {
|
||||
site, err := m.store.GetStaticSiteByID(id)
|
||||
siteName := id
|
||||
if err == nil {
|
||||
siteName = site.Name
|
||||
}
|
||||
m.publishEvent(id, siteName, "failed: "+errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// publishEvent publishes a static site status event on the event bus
|
||||
// and persists it to the event log for the dashboard.
|
||||
func (m *Manager) publishEvent(siteID, siteName, status string) {
|
||||
m.eventBus.Publish(events.Event{
|
||||
Type: events.EventStaticSiteStatus,
|
||||
Payload: events.StaticSiteStatusPayload{
|
||||
SiteID: siteID,
|
||||
Name: siteName,
|
||||
Status: status,
|
||||
},
|
||||
})
|
||||
|
||||
// Persist to event log.
|
||||
severity := "info"
|
||||
message := fmt.Sprintf("Static site \"%s\": %s", siteName, status)
|
||||
if status == "failed" {
|
||||
severity = "error"
|
||||
}
|
||||
metadata := fmt.Sprintf(`{"site_id":"%s","site_name":"%s","status":"%s"}`, siteID, siteName, status)
|
||||
|
||||
evt, err := m.store.InsertEvent(store.EventLog{
|
||||
Source: "static_site",
|
||||
Severity: severity,
|
||||
Message: message,
|
||||
Metadata: metadata,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("static site: failed to persist event log", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Publish the persisted event for SSE clients.
|
||||
m.eventBus.Publish(events.Event{
|
||||
Type: events.EventLog,
|
||||
Payload: events.EventLogPayload{
|
||||
ID: evt.ID,
|
||||
Source: "static_site",
|
||||
Severity: severity,
|
||||
Message: message,
|
||||
Metadata: metadata,
|
||||
CreatedAt: evt.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// copyDir recursively copies a directory.
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, 0o755)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(dstPath, data, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
// hostPortStr converts a uint16 port to a string for proxy configuration.
|
||||
func hostPortStr(port uint16) string {
|
||||
return strconv.FormatUint(uint64(port), 10)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package staticsite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
// RenderMarkdownFiles walks the directory and converts all .md files to .html.
|
||||
// The original .md file is kept alongside the generated .html file.
|
||||
func RenderMarkdownFiles(dir string) error {
|
||||
md := goldmark.New()
|
||||
|
||||
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".md" && ext != ".markdown" {
|
||||
return nil
|
||||
}
|
||||
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert(src, &buf); err != nil {
|
||||
return fmt.Errorf("render %s: %w", path, err)
|
||||
}
|
||||
|
||||
html := wrapHTML(extractTitle(src), buf.String())
|
||||
|
||||
htmlPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".html"
|
||||
if err := os.WriteFile(htmlPath, []byte(html), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", htmlPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// extractTitle finds the first # heading in markdown content.
|
||||
func extractTitle(src []byte) string {
|
||||
for _, line := range strings.Split(string(src), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "# ") {
|
||||
return strings.TrimPrefix(trimmed, "# ")
|
||||
}
|
||||
}
|
||||
return "Page"
|
||||
}
|
||||
|
||||
// wrapHTML wraps rendered markdown in a minimal HTML document.
|
||||
func wrapHTML(title, body string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; color: #333; }
|
||||
pre { background: #f4f4f4; padding: 1rem; border-radius: 4px; overflow-x: auto; }
|
||||
code { background: #f4f4f4; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
|
||||
pre code { background: none; padding: 0; }
|
||||
img { max-width: 100%%; height: auto; }
|
||||
a { color: #0366d6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
%s
|
||||
</body>
|
||||
</html>`, title, body)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package staticsite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RepoInfo represents a repository returned by the provider's list/search API.
|
||||
type RepoInfo struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"` // "owner/name"
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// GitProvider abstracts Git hosting API operations.
|
||||
// Implementations exist for Gitea/Forgejo/Gogs, GitHub, and GitLab.
|
||||
type GitProvider interface {
|
||||
// Name returns the provider identifier (e.g., "gitea", "github", "gitlab").
|
||||
Name() string
|
||||
|
||||
// TestConnection verifies that the repository is accessible.
|
||||
TestConnection(ctx context.Context, owner, repo string) error
|
||||
|
||||
// ListRepos returns repositories accessible with the current token.
|
||||
// If query is non-empty, results are filtered by name.
|
||||
ListRepos(ctx context.Context, query string) ([]RepoInfo, error)
|
||||
|
||||
// ListBranches returns all branch names for a repository.
|
||||
ListBranches(ctx context.Context, owner, repo string) ([]string, error)
|
||||
|
||||
// GetLatestCommitSHA returns the latest commit SHA for a branch.
|
||||
GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error)
|
||||
|
||||
// ListTree returns the full directory tree for a branch.
|
||||
ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error)
|
||||
|
||||
// DownloadFolder downloads all files from a folder path to a local directory.
|
||||
DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error
|
||||
}
|
||||
|
||||
// ProviderType identifies a Git hosting provider.
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
ProviderGitea ProviderType = "gitea" // Also works for Forgejo and Gogs.
|
||||
ProviderGitHub ProviderType = "github"
|
||||
ProviderGitLab ProviderType = "gitlab"
|
||||
)
|
||||
|
||||
// ValidProviderTypes lists all supported provider types.
|
||||
var ValidProviderTypes = []ProviderType{ProviderGitea, ProviderGitHub, ProviderGitLab}
|
||||
|
||||
// NewGitProvider creates a GitProvider for the given type.
|
||||
// If providerType is empty, it attempts autodetection from the baseURL.
|
||||
func NewGitProvider(providerType ProviderType, baseURL, token string) (GitProvider, error) {
|
||||
if providerType == "" {
|
||||
providerType = DetectProvider(baseURL)
|
||||
}
|
||||
|
||||
switch providerType {
|
||||
case ProviderGitea:
|
||||
return NewGiteaContentFetcher(baseURL, token), nil
|
||||
case ProviderGitHub:
|
||||
return NewGitHubProvider(baseURL, token), nil
|
||||
case ProviderGitLab:
|
||||
return NewGitLabProvider(baseURL, token), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported git provider: %s", providerType)
|
||||
}
|
||||
}
|
||||
|
||||
// DetectProvider guesses the provider type from a base URL.
|
||||
func DetectProvider(baseURL string) ProviderType {
|
||||
lower := strings.ToLower(baseURL)
|
||||
switch {
|
||||
case strings.Contains(lower, "github.com"):
|
||||
return ProviderGitHub
|
||||
case strings.Contains(lower, "gitlab.com"):
|
||||
return ProviderGitLab
|
||||
default:
|
||||
// Default to Gitea for self-hosted instances (Gitea/Forgejo/Gogs all share the same API).
|
||||
return ProviderGitea
|
||||
}
|
||||
}
|
||||
|
||||
// DetectProviderWithProbe tries to autodetect the provider by probing known API endpoints.
|
||||
// Falls back to URL-based detection if probing fails.
|
||||
func DetectProviderWithProbe(ctx context.Context, baseURL string) ProviderType {
|
||||
// First try URL-based detection for well-known hosts.
|
||||
urlBased := DetectProvider(baseURL)
|
||||
if urlBased == ProviderGitHub || urlBased == ProviderGitLab {
|
||||
return urlBased
|
||||
}
|
||||
|
||||
// For unknown hosts, probe for Gitea/GitLab API signatures.
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
base := strings.TrimRight(baseURL, "/")
|
||||
|
||||
// Try Gitea/Forgejo API.
|
||||
if resp, err := httpGet(ctx, client, base+"/api/v1/version"); err == nil && resp == http.StatusOK {
|
||||
return ProviderGitea
|
||||
}
|
||||
|
||||
// Try GitLab API.
|
||||
if resp, err := httpGet(ctx, client, base+"/api/v4/version"); err == nil && resp == http.StatusOK {
|
||||
return ProviderGitLab
|
||||
}
|
||||
|
||||
// Default to Gitea.
|
||||
return ProviderGitea
|
||||
}
|
||||
|
||||
// httpGet performs a simple GET and returns the status code.
|
||||
func httpGet(ctx context.Context, client *http.Client, url string) (int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// downloadFileHTTP is a shared helper for downloading a file from a URL.
|
||||
func downloadFileHTTP(ctx context.Context, client *http.Client, url, localPath string, authHeader func(r *http.Request)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if authHeader != nil {
|
||||
authHeader(req)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||
return fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user