package koji
import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"hash/adler32"
"io"
"io/ioutil"
"net/http"
"net/url"
"github.com/kolo/xmlrpc"
)
type Koji struct {
sessionID int64
sessionKey string
callnum int
xmlrpc *xmlrpc.Client
server string
}
type BuildExtra struct {
Image interface{} `json:"image"` // No extra info tracked at build level.
}
type Build struct {
Name string `json:"name"`
Version string `json:"version"`
Release string `json:"release"`
Source string `json:"source"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
Extra BuildExtra `json:"extra"`
}
type Host struct {
Os string `json:"os"`
Arch string `json:"arch"`
}
type ContentGenerator struct {
Name string `json:"name"`
Version string `json:"version"`
}
type Container struct {
Type string `json:"type"`
Arch string `json:"arch"`
}
type Tool struct {
Name string `json:"name"`
Version string `json:"version"`
}
type Component struct {
Type string `json:"type"` // must be 'rpm'
Name string `json:"name"`
Version string `json:"version"`
Release string `json:"release"`
Epoch *uint64 `json:"epoch"`
Arch string `json:"arch"`
Sigmd5 string `json:"sigmd5"`
Signature *string `json:"signature"`
}
type BuildRoot struct {
ID uint64 `json:"id"`
Host Host `json:"host"`
ContentGenerator ContentGenerator `json:"content_generator"`
Container Container `json:"container"`
Tools []Tool `json:"tools"`
Components []Component `json:"components"`
}
type OutputExtraImageInfo struct {
// TODO: Ideally this is where the pipeline would be passed.
Arch string `json:"arch"` // TODO: why?
}
type OutputExtra struct {
Image OutputExtraImageInfo `json:"image"`
}
type Output struct {
BuildRootID uint64 `json:"buildroot_id"`
Filename string `json:"filename"`
FileSize uint64 `json:"filesize"`
Arch string `json:"arch"`
ChecksumType string `json:"checksum_type"` // must be 'md5'
MD5 string `json:"checksum"`
Type string `json:"type"`
Components []Component `json:"component"`
Extra OutputExtra `json:"extra"`
}
type Metadata struct {
MetadataVersion int `json:"metadata_version"` // must be '0'
Build Build `json:"build"`
BuildRoots []BuildRoot `json:"buildroots"`
Output []Output `json:"output"`
}
type CGImportResult struct {
BuildID int `xmlrpc:"build_id"`
}
// RoundTrip implements the RoundTripper interface, using the default
// transport. When a session has been established, also pass along the
// session credentials. This may not be how the RoundTripper interface
// is meant to be used, but the underlying XML-RPC helpers don't allow
// us to adjust the URL per-call (these arguments should really be in
// the body).
func (k *Koji) RoundTrip(req *http.Request) (*http.Response, error) {
if k.sessionKey == "" {
return http.DefaultTransport.RoundTrip(req)
}
// Clone the request, so as not to alter the passed in value.
rClone := new(http.Request)
*rClone = *req
rClone.Header = make(http.Header, len(req.Header))
for idx, header := range req.Header {
rClone.Header[idx] = append([]string(nil), header...)
}
values := rClone.URL.Query()
values.Add("session-id", fmt.Sprintf("%v", k.sessionID))
values.Add("session-key", k.sessionKey)
values.Add("callnum", fmt.Sprintf("%v", k.callnum))
rClone.URL.RawQuery = values.Encode()
// Each call is given a unique callnum.
k.callnum++
return http.DefaultTransport.RoundTrip(rClone)
}
func New(server string) (*Koji, error) {
k := &Koji{}
client, err := xmlrpc.NewClient(server, k)
if err != nil {
return nil, err
}
k.xmlrpc = client
k.server = server
return k, nil
}
// GetAPIVersion gets the version of the API of the remote Koji instance
func (k *Koji) GetAPIVersion() (int, error) {
var version int
err := k.xmlrpc.Call("getAPIVersion", nil, &version)
if err != nil {
return 0, err
}
return version, nil
}
// Login sets up a new session with the given user/password
func (k *Koji) Login(user, password string) error {
args := []interface{}{user, password}
var reply struct {
SessionID int64 `xmlrpc:"session-id"`
SessionKey string `xmlrpc:"session-key"`
}
err := k.xmlrpc.Call("login", args, &reply)
if err != nil {
return err
}
k.sessionID = reply.SessionID
k.sessionKey = reply.SessionKey
k.callnum = 0
return nil
}
// Logout ends the session
func (k *Koji) Logout() error {
err := k.xmlrpc.Call("logout", nil, nil)
if err != nil {
return err
}
return nil
}
// CGImport imports previously uploaded content, by specifying its metadata, and the temporary
// directory where it is located.
func (k *Koji) CGImport(build Build, buildRoots []BuildRoot, output []Output, directory string) (*CGImportResult, error) {
m := &Metadata{
Build: build,
BuildRoots: buildRoots,
Output: output,
}
metadata, err := json.Marshal(m)
if err != nil {
return nil, err
}
var result CGImportResult
err = k.xmlrpc.Call("CGImport", []interface{}{string(metadata), directory}, &result)
if err != nil {
return nil, err
}
return &result, nil
}
// uploadChunk uploads a byte slice to a given filepath/filname at a given offset
func (k *Koji) uploadChunk(chunk []byte, filepath, filename string, offset uint64) error {
// We have to open-code a bastardized version of XML-RPC: We send an octet-stream, as
// if it was an RPC call, and get a regular XML-RPC reply back. In addition to the
// standard URL parameters, we also have to pass any other parameters as part of the
// URL, as the body can only contain the payload.
u, err := url.Parse(k.server)
if err != nil {
return err
}
q := u.Query()
q.Add("filepath", filepath)
q.Add("filename", filename)
q.Add("offset", fmt.Sprintf("%v", offset))
q.Add("fileverify", "adler32")
q.Add("session-id", fmt.Sprintf("%v", k.sessionID))
q.Add("session-key", k.sessionKey)
q.Add("callnum", fmt.Sprintf("%v", k.callnum))
u.RawQuery = q.Encode()
// Each call is given a unique callnum.
k.callnum++
resp, err := http.Post(u.String(), "application/octet-stream", bytes.NewBuffer(chunk))
if err != nil {
return err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
err = xmlrpc.Response.Err(body)
if err != nil {
return err
}
var reply struct {
Size int `xmlrpc:"size"`
HexDigest string `xmlrpc:"hexdigest"`
}
err = xmlrpc.Response.Unmarshal(body, &reply)
if err != nil {
return fmt.Errorf("cannot unmarshal the xmlrpc response: %v", err)
}
if reply.Size != len(chunk) {
return fmt.Errorf("Sent a chunk of %d bytes, but server got %d bytes", len(chunk), reply.Size)
}
digest := fmt.Sprintf("%08x", adler32.Checksum(chunk))
if reply.HexDigest != digest {
return fmt.Errorf("Sent a chunk with Adler32 digest %s, but server computed digest %s", digest, reply.HexDigest)
}
return nil
}
// Upload uploads file to the temporary filepath on the kojiserver under the name filename
// The md5sum and size of the file is returned on success.
func (k *Koji) Upload(file io.Reader, filepath, filename string) (string, uint64, error) {
chunk := make([]byte, 1024*1024) // upload a megabyte at a time
offset := uint64(0)
hash := md5.New()
for {
n, err := file.Read(chunk)
if err != nil {
if err == io.EOF {
break
}
return "", 0, err
}
err = k.uploadChunk(chunk[:n], filepath, filename, offset)
if err != nil {
return "", 0, err
}
offset += uint64(n)
m, err := hash.Write(chunk[:n])
if err != nil {
return "", 0, err
}
if m != n {
return "", 0, fmt.Errorf("sent %d bytes, but hashed %d", n, m)
}
}
return fmt.Sprintf("%x", hash.Sum(nil)), offset, nil
}