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:
2026-04-11 03:35:57 +03:00
parent b0816502bf
commit 8d2c5a063b
31 changed files with 4967 additions and 5 deletions
+253
View File
@@ -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;"]
`
}
+360
View File
@@ -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
}
+276
View File
@@ -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)
}
}
+254
View File
@@ -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)
}
}
+111
View File
@@ -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")
}
}
}
+691
View File
@@ -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)
}
+83
View File
@@ -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)
}
+171
View File
@@ -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
}