Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions forgejo/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
GO = go
EXT =
PLAKAR ?= plakar
VERSION ?= v1.0.0

GOOS := $(shell go env GOOS)
GOARCH := $(shell go env GOARCH)
PTAR := forgejo_$(VERSION)_$(GOOS)_$(GOARCH).ptar

all: build

build:
${GO} build -v -o forgejoImporter${EXT} ./plugin/importer
${GO} build -v -o forgejoExporter${EXT} ./plugin/exporter

package: build
rm -f $(PTAR)
$(PLAKAR) pkg create ./manifest.yaml $(VERSION)

test:
${GO} test ./...

clean:
rm -f forgejoImporter forgejoExporter forgejo_*.ptar

.PHONY: all build package test clean
30 changes: 30 additions & 0 deletions forgejo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Forgejo integration for Plakar

This integration creates a Plakar snapshot from a Forgejo dump archive.

## Import

```sh
plakar at /path/to/store backup forgejo://local?forgejo_bin=/usr/local/bin/forgejo&config=/etc/forgejo/app.ini
```

The importer runs:

```sh
forgejo dump --type zip --file <temporary-directory>
```

Optional query parameters:

- `forgejo_bin`: path to the Forgejo binary. Defaults to `forgejo`.
- `config`: path to Forgejo `app.ini`.
- `work_path`: Forgejo work path.
- `timeout`: Go duration for the dump command. Defaults to `30m`.

## Export

```sh
plakar at /path/to/store restore -to forgejo://local?output_dir=/tmp/forgejo-restore
```

The exporter writes `forgejo-dump.zip` to `output_dir`. Administrators can then restore it with the Forgejo restore procedure that matches their deployment.
202 changes: 202 additions & 0 deletions forgejo/connector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package forgejo

import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"

"github.com/PlakarKorp/kloset/connectors"
"github.com/PlakarKorp/kloset/connectors/exporter"
"github.com/PlakarKorp/kloset/connectors/importer"
"github.com/PlakarKorp/kloset/location"
"github.com/PlakarKorp/kloset/objects"
)

const dumpPath = "/forgejo-dump.zip"

type Connector struct {
forgejoBin string
configPath string
workPath string
outputDir string
timeout time.Duration
}

func init() {
importer.Register("forgejo", location.FLAG_LOCALFS, NewImporter)
exporter.Register("forgejo", location.FLAG_LOCALFS, NewExporter)
}

func NewImporter(ctx context.Context, opts *connectors.Options, proto string, config map[string]string) (importer.Importer, error) {
conn, err := newConnector(config)
if err != nil {
return nil, err
}
return conn, nil
}

func NewExporter(ctx context.Context, opts *connectors.Options, proto string, config map[string]string) (exporter.Exporter, error) {
conn, err := newConnector(config)
if err != nil {
return nil, err
}
if conn.outputDir == "" {
conn.outputDir = "."
}
return conn, nil
}

func newConnector(config map[string]string) (*Connector, error) {
timeout := 30 * time.Minute
if raw := config["timeout"]; raw != "" {
parsed, err := time.ParseDuration(raw)
if err != nil {
return nil, fmt.Errorf("invalid timeout: %w", err)
}
timeout = parsed
}

return &Connector{
forgejoBin: valueOrDefault(config["forgejo_bin"], "forgejo"),
configPath: config["config"],
workPath: config["work_path"],
outputDir: config["output_dir"],
timeout: timeout,
}, nil
}

func valueOrDefault(value, fallback string) string {
if value != "" {
return value
}
return fallback
}

func (c *Connector) Origin() string { return "localhost" }
func (c *Connector) Root() string { return "/" }
func (c *Connector) Type() string { return "forgejo" }
func (c *Connector) Flags() location.Flags { return location.FLAG_LOCALFS | location.FLAG_NEEDACK }

func (c *Connector) Ping(ctx context.Context) error {
cmd := exec.CommandContext(ctx, c.forgejoBin, "--version")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("running %s --version: %w: %s", c.forgejoBin, err, strings.TrimSpace(string(out)))
}
return nil
}

func (c *Connector) Import(ctx context.Context, records chan<- *connectors.Record, results <-chan *connectors.Result) error {
defer close(records)

tmpDir, err := os.MkdirTemp("", "plakar-forgejo-*")
if err != nil {
return err
}

reader := func() (io.ReadCloser, error) {
return c.runDump(ctx, tmpDir)
}

info := objects.FileInfo{
Lname: path.Base(dumpPath),
Lsize: -1,
Lmode: 0o444,
LmodTime: time.Now().UTC(),
}

records <- connectors.NewRecord(dumpPath, "", info, nil, reader)
res := <-results
_ = os.RemoveAll(tmpDir)
return res.Err
}

func (c *Connector) runDump(ctx context.Context, tmpDir string) (io.ReadCloser, error) {
dumpCtx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()

args := []string{"dump", "--type", "zip", "--file", tmpDir}
if c.configPath != "" {
args = append(args, "--config", c.configPath)
}
if c.workPath != "" {
args = append(args, "--work-path", c.workPath)
}

cmd := exec.CommandContext(dumpCtx, c.forgejoBin, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("running forgejo dump: %w: %s", err, strings.TrimSpace(string(out)))
}

matches, err := filepath.Glob(filepath.Join(tmpDir, "*.zip"))
if err != nil {
return nil, err
}
if len(matches) == 0 {
return nil, fmt.Errorf("forgejo dump did not create a zip archive in %s", tmpDir)
}

return os.Open(matches[0])
}

func (c *Connector) Export(ctx context.Context, records <-chan *connectors.Record, results chan<- *connectors.Result) error {
defer close(results)

if err := os.MkdirAll(c.outputDir, 0o755); err != nil {
return err
}

for record := range records {
if record.Err != nil {
results <- record.Ok()
continue
}
if record.FileInfo.Lmode.IsDir() {
results <- record.Ok()
continue
}
if filepath.Base(record.Pathname) != filepath.Base(dumpPath) {
results <- record.Error(fmt.Errorf("unexpected Forgejo backup file: %s", record.Pathname))
continue
}
if err := c.writeDump(record); err != nil {
results <- record.Error(err)
continue
}
results <- record.Ok()
}

return nil
}

func (c *Connector) writeDump(record *connectors.Record) error {
if record.Reader == nil {
return fmt.Errorf("record %s has no reader", record.Pathname)
}

target := filepath.Join(c.outputDir, filepath.Base(dumpPath))
tmp := target + ".tmp"

out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(out, record.Reader); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, target)
}

func (c *Connector) Close(ctx context.Context) error { return nil }
82 changes: 82 additions & 0 deletions forgejo/connector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package forgejo

import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/PlakarKorp/kloset/connectors"
"github.com/PlakarKorp/kloset/objects"
)

func TestNewConnectorDefaults(t *testing.T) {
conn, err := newConnector(map[string]string{})
if err != nil {
t.Fatal(err)
}
if conn.forgejoBin != "forgejo" {
t.Fatalf("forgejoBin = %q, want forgejo", conn.forgejoBin)
}
if conn.timeout != 30*time.Minute {
t.Fatalf("timeout = %s, want 30m", conn.timeout)
}
}

func TestNewConnectorCustomTimeout(t *testing.T) {
conn, err := newConnector(map[string]string{
"forgejo_bin": "/usr/local/bin/forgejo",
"timeout": "2m",
})
if err != nil {
t.Fatal(err)
}
if conn.forgejoBin != "/usr/local/bin/forgejo" {
t.Fatalf("forgejoBin = %q", conn.forgejoBin)
}
if conn.timeout != 2*time.Minute {
t.Fatalf("timeout = %s, want 2m", conn.timeout)
}
}

func TestNewConnectorInvalidTimeout(t *testing.T) {
if _, err := newConnector(map[string]string{"timeout": "soon"}); err == nil {
t.Fatal("expected invalid timeout error")
}
}

func TestExporterWritesForgejoDump(t *testing.T) {
outDir := t.TempDir()
exp, err := NewExporter(context.Background(), &connectors.Options{}, "forgejo", map[string]string{
"output_dir": outDir,
})
if err != nil {
t.Fatal(err)
}

records := make(chan *connectors.Record, 1)
results := make(chan *connectors.Result, 1)
records <- connectors.NewRecord(dumpPath, "", objects.FileInfo{Lmode: 0o444}, nil, func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("forgejo dump bytes")), nil
})
close(records)

if err := exp.Export(context.Background(), records, results); err != nil {
t.Fatal(err)
}
result := <-results
if result.Err != nil {
t.Fatal(result.Err)
}

got, err := os.ReadFile(filepath.Join(outDir, filepath.Base(dumpPath)))
if err != nil {
t.Fatal(err)
}
if string(got) != "forgejo dump bytes" {
t.Fatalf("dump contents = %q", got)
}
}
9 changes: 9 additions & 0 deletions forgejo/exporter/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"location": { "type": "string" },
"output_dir": { "type": "string" }
},
"additionalProperties": true
}
39 changes: 39 additions & 0 deletions forgejo/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
module github.com/PlakarKorp/integration-forgejo

go 1.25.5

require (
github.com/PlakarKorp/go-kloset-sdk v1.1.0-beta.1
github.com/PlakarKorp/kloset v1.1.0-beta.2
)

require (
github.com/PlakarKorp/integration-grpc v1.1.0-beta.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nickball/go-aes-key-wrap v0.0.0-20170929221519-1c3aa3e4dfc5 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tink-crypto/tink-go/v2 v2.6.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
)
Loading