-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit c32509d
Showing
5 changed files
with
677 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
JIRA Replicator | ||
=============== | ||
|
||
A library (and tool) to automatically back-up your JIRA instance. | ||
|
||
Especially handy for JIRA Cloud users. | ||
|
||
|
||
Tool Usage | ||
---------- | ||
|
||
### Configuration | ||
|
||
Configuration is done using the following environment variables. | ||
|
||
* `JIRA_URL` | ||
* `JIRA_USERNAME` | ||
* `JIRA_PASSWORD` | ||
|
||
### Usage | ||
|
||
Generate the backup using: | ||
|
||
$ jira-backup backup | ||
|
||
Download the backup using: | ||
|
||
$ jira-backup download -o FILE_PATH | ||
|
||
Copy the backup to S3: | ||
|
||
$ jira-backup s3 | ||
|
||
Run it on a loop to make a backup every 48 hours: | ||
|
||
$ jira-backup daemon | ||
|
||
Please see `jira-backup help s3` for full docs on all available options and how to set defaults. | ||
|
||
Backups on JIRA cloud are limited to once per 48 hours (after completion of previous backup). | ||
If you exceed this you will be notified of when you can re-try the request. | ||
|
||
### Library Usage | ||
|
||
See godoc. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
package client | ||
|
||
import ( | ||
"time" | ||
"fmt" | ||
"regexp" | ||
"strings" | ||
) | ||
|
||
// BackupRateExceeded is an error which means that the | ||
// rate limit on the backup API has been exceeded. | ||
type BackupRateExceeded struct { | ||
retryAt time.Time | ||
} | ||
|
||
func (e BackupRateExceeded) Error() string { | ||
return fmt.Sprintf("Backup rate exceeded. Retry in %s", e.RetryIn()) | ||
} | ||
|
||
|
||
func (e BackupRateExceeded) RetryIn() time.Duration { | ||
return e.retryAt.Sub(time.Now()) | ||
} | ||
|
||
func (e BackupRateExceeded) RetryAt() time.Time { | ||
return e.retryAt | ||
} | ||
|
||
// FromResponse creates a BackupRateExceeded from the given text. | ||
// It will attempt to extract the retry time from the text. | ||
func (BackupRateExceeded) FromResponse(text string) BackupRateExceeded { | ||
|
||
// Helpfully Atlassian gives the response time in a format easily passed | ||
// to time.ParseDuration | ||
exp := regexp.MustCompile("((\\d+)h)?((\\d+)m)?") | ||
|
||
matches := exp.FindAllString(text, -1) | ||
durationStr := strings.Join(matches, "") | ||
|
||
dur, err := time.ParseDuration(durationStr) | ||
|
||
if err == nil { | ||
return BackupRateExceeded { | ||
retryAt: time.Now().Round(time.Minute).Add(dur), | ||
} | ||
} | ||
|
||
return BackupRateExceeded{retryAt: time.Now()} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,223 @@ | ||
package client | ||
|
||
import ( | ||
"net/url" | ||
"net/http" | ||
"log" | ||
"os" | ||
"io/ioutil" | ||
"encoding/json" | ||
"errors" | ||
"bytes" | ||
|
||
"io" | ||
"time" | ||
"strconv" | ||
) | ||
|
||
// Client represents a JIRA backup client | ||
type Client struct { | ||
BaseURL url.URL | ||
HTTP *http.Client | ||
Log *log.Logger | ||
|
||
username string | ||
password string | ||
} | ||
|
||
type BackupStatus struct { | ||
Status string `json:"status"` | ||
Progress int `json:"progress"` | ||
DownloadPath string `json:"result"` | ||
} | ||
|
||
|
||
// New creates a new JIRA Backup Client | ||
func New(baseURL *url.URL, username, password string) *Client { | ||
|
||
return &Client{ | ||
BaseURL: *baseURL, | ||
HTTP: &http.Client{Transport: http.DefaultTransport}, | ||
Log: log.New(os.Stdout, "jira-client ", log.LstdFlags), | ||
|
||
username: username, | ||
password: password, | ||
} | ||
} | ||
|
||
func (c Client) makeRequest(req *http.Request) (*http.Response, error) { | ||
|
||
if req.Header == nil { | ||
req.Header = http.Header{} | ||
} | ||
|
||
req.Header.Set("User-Agent", "Jira Replicator +https://bitbucket.org/mr-zen/jira-replicator") | ||
req.SetBasicAuth(c.username, c.password) | ||
|
||
st := time.Now() | ||
res, err := c.HTTP.Do(req) | ||
dt := time.Now().Sub(st) | ||
|
||
if err == nil { | ||
c.Log.Println(req.Method, req.URL, res.StatusCode, res.Header.Get("Content-Length"), dt) | ||
} | ||
|
||
return res, err | ||
} | ||
|
||
// CreateBackup creates a new JIRA backup. | ||
func (c Client) CreateBackup(includeAttachments bool) error { | ||
|
||
u := c.BaseURL | ||
u.Path = "/rest/backup/1/export/runbackup" | ||
|
||
req := &http.Request{ | ||
Method: http.MethodPost, | ||
URL: &u, | ||
Header: http.Header{ | ||
"Accept": []string{"application/json"}, | ||
"Content-Type": []string{"application/json"}, | ||
}, | ||
} | ||
|
||
params := make(map[string]bool) | ||
params["cbAttachments"] = includeAttachments | ||
body, _ := json.Marshal(params) | ||
|
||
req.Body = ioutil.NopCloser(bytes.NewReader(body)) | ||
|
||
res, err := c.makeRequest(req) | ||
|
||
if err != nil { | ||
return err | ||
} | ||
|
||
if res.StatusCode >= 400 { | ||
if res.StatusCode == http.StatusPreconditionFailed /* 412 */ { | ||
// We got us some json, let's get some deets. | ||
body, err := ioutil.ReadAll(res.Body) | ||
|
||
if err != nil { | ||
return err | ||
} | ||
|
||
respData := make(map[string]string) | ||
|
||
err = json.Unmarshal(body, &respData) | ||
|
||
if err != nil { | ||
return err | ||
} | ||
|
||
return BackupRateExceeded{}.FromResponse(respData["error"]) | ||
} | ||
|
||
content, _ := ioutil.ReadAll(res.Body) | ||
|
||
return errors.New(string(content)) | ||
} | ||
|
||
|
||
|
||
return err | ||
} | ||
|
||
// Download backup downloads the current JIRA backup | ||
func (c Client) DownloadBackup() (io.ReadCloser, int, error) { | ||
|
||
// Check the backup is ready and waiting. | ||
status, err := c.GetBackupStatus() | ||
|
||
if err != nil { | ||
return nil, 0, err | ||
} | ||
|
||
if status.Status != "Success" { | ||
return nil, 0, errors.New(status.Status) | ||
} | ||
|
||
downloadURL, err := url.Parse(status.DownloadPath) | ||
|
||
u := c.BaseURL | ||
u.Path = "/plugins/servlet/"+downloadURL.Path | ||
u.RawQuery = downloadURL.Query().Encode() | ||
|
||
req := &http.Request{ | ||
Method: http.MethodGet, | ||
URL: &u, | ||
} | ||
|
||
res, err := c.makeRequest(req) | ||
|
||
if err != nil { | ||
return nil, 0, err | ||
} | ||
|
||
length, _ := strconv.ParseInt(res.Header.Get("Content-Length"), 10, 64) | ||
|
||
|
||
return res.Body, int(length), nil | ||
} | ||
|
||
func (c Client) GetBackupStatus() (BackupStatus, error) { | ||
|
||
u := c.BaseURL | ||
u.Path = "/rest/backup/1/export/lastTaskId" | ||
q := u.Query() | ||
q.Set("_", strconv.Itoa(int(time.Now().Unix()))) | ||
u.RawQuery = q.Encode() | ||
|
||
// First determine the task ID. | ||
req := &http.Request{ | ||
Method: http.MethodGet, | ||
URL: &u, | ||
Header: http.Header{ | ||
"Accept": []string{"application/json"}, | ||
}, | ||
} | ||
|
||
res, err := c.makeRequest(req) | ||
|
||
if err != nil { | ||
return BackupStatus{}, err | ||
} | ||
|
||
if res.StatusCode >= 400 { | ||
return BackupStatus{}, errors.New(res.Status) | ||
} | ||
|
||
body, _ := ioutil.ReadAll(res.Body) | ||
taskId := string(body) | ||
|
||
// Get the status | ||
u = c.BaseURL | ||
u.Path = "/rest/backup/1/export/getProgress" | ||
q = u.Query() | ||
q.Set("taskId", taskId) | ||
q.Set("_", strconv.Itoa(int(time.Now().Unix()))) | ||
u.RawQuery = q.Encode() | ||
|
||
req = &http.Request{ | ||
Method: http.MethodGet, | ||
URL: &u, | ||
Header: http.Header{ | ||
"Accept": []string{"application/json"}, | ||
}, | ||
} | ||
|
||
res, err = c.makeRequest(req) | ||
|
||
if err != nil { | ||
return BackupStatus{}, err | ||
} | ||
|
||
body, _ = ioutil.ReadAll(res.Body) | ||
|
||
var b BackupStatus | ||
err = json.Unmarshal(body, &b) | ||
|
||
return b, err | ||
|
||
return b, err | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
[s3] | ||
region = "eu-west-1" | ||
bucket = "YOUR_JIRA_BACKUP_BUCKET" | ||
storage_class = "STANDARD_IA" # Recommended for backups. | ||
|
||
[kms] | ||
key = "arn:aws:kms:eu-west-1:AWS_ACCOUNT_ID:key/KEY_UUID" # Set this to enable SSE. |
Oops, something went wrong.