Repository: osbuild/osbuild-composer
Pull request #94 (2019-11-12 16:25:16)

Blueprint changes
Every update to a blueprint creates a change. Each change contains a commit which is a hex string based off of an sha1 hash of the current time, a message describing the change, a revision which will usually be null, a timestamp, and the blueprint at the time of the change. Even when a blueprint is deleted the list of changes remains and will be updated if a blueprint with the same name is created again.

jkozol: I squashed the api commit and test commit together. On my branch the tests commit came after the api one but because of the timestamp github has the test commit before the api functionality.

Patch:
diff --git a/internal/blueprint/blueprint.go b/internal/blueprint/blueprint.go
index 3d1016666..a7f9a0477 100644
--- a/internal/blueprint/blueprint.go
+++ b/internal/blueprint/blueprint.go
@@ -22,6 +22,14 @@ type Blueprint struct {
 	Customizations *Customizations `json:"customizations,omitempty"`
 }
 
+type Change struct {
+	Commit    string    `json:"commit"`
+	Message   string    `json:"message"`
+	Revision  *string   `json:"revision"`
+	Timestamp string    `json:"timestamp"`
+	Blueprint Blueprint `json:"-"`
+}
+
 // A Package specifies an RPM package.
 type Package struct {
 	Name    string `json:"name"`
@@ -42,10 +50,10 @@ func (b *Blueprint) GetKernelCustomization() *KernelCustomization {
 }
 
 func (p Package) ToNameVersion() string {
-       // Omit version to prevent all packages with prefix of name to be installed
-       if p.Version == "*" {
-               return p.Name
-       }
+	// Omit version to prevent all packages with prefix of name to be installed
+	if p.Version == "*" {
+		return p.Name
+	}
 
-       return p.Name + "-" + p.Version
+	return p.Name + "-" + p.Version
 }
diff --git a/internal/rpmmd/repository.go b/internal/rpmmd/repository.go
index 1ec88979f..5a5e958f6 100644
--- a/internal/rpmmd/repository.go
+++ b/internal/rpmmd/repository.go
@@ -86,6 +86,9 @@ func FetchPackageList(repos []RepoConfig) (PackageList, error) {
 	}{repos}
 	var packages PackageList
 	err := runDNF("dump", arguments, &packages)
+	sort.Slice(packages, func(i, j int) bool {
+		return packages[i].Name < packages[j].Name
+	})
 	return packages, err
 }
 
diff --git a/internal/store/store.go b/internal/store/store.go
index 6816be987..aefa11ce1 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -3,6 +3,8 @@
 package store
 
 import (
+	"crypto/sha1"
+	"encoding/hex"
 	"encoding/json"
 	"io/ioutil"
 	"log"
@@ -25,10 +27,11 @@ import (
 // A Store contains all the persistent state of osbuild-composer, and is serialized
 // on every change, and deserialized on start.
 type Store struct {
-	Blueprints map[string]blueprint.Blueprint `json:"blueprints"`
-	Workspace  map[string]blueprint.Blueprint `json:"workspace"`
-	Composes   map[uuid.UUID]Compose          `json:"composes"`
-	Sources    map[string]SourceConfig        `json:"sources"`
+	Blueprints        map[string]blueprint.Blueprint         `json:"blueprints"`
+	Workspace         map[string]blueprint.Blueprint         `json:"workspace"`
+	Composes          map[uuid.UUID]Compose                  `json:"composes"`
+	Sources           map[string]SourceConfig                `json:"sources"`
+	BlueprintsChanges map[string]map[string]blueprint.Change `json:"changes"`
 
 	mu           sync.RWMutex // protects all fields
 	pendingJobs  chan Job
@@ -142,6 +145,9 @@ func New(stateFile *string) *Store {
 	if s.Sources == nil {
 		s.Sources = make(map[string]SourceConfig)
 	}
+	if s.BlueprintsChanges == nil {
+		s.BlueprintsChanges = make(map[string]map[string]blueprint.Change)
+	}
 	s.pendingJobs = make(chan Job, 200)
 
 	return &s
@@ -362,9 +368,42 @@ func (s *Store) GetBlueprintCommitted(name string, bp *blueprint.Blueprint) bool
 	return true
 }
 
+func (s *Store) GetBlueprintChanges(name string) []blueprint.Change {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	var changes []blueprint.Change
+
+	for _, change := range s.BlueprintsChanges[name] {
+		changes = append(changes, change)
+	}
+
+	return changes
+}
+
 func (s *Store) PushBlueprint(bp blueprint.Blueprint) {
 	s.change(func() error {
+		hash := sha1.New()
+		// Hash timestamp to create unique hash
+		hash.Write([]byte(time.Now().String()))
+		// Get hash as a byte slice
+		commitBytes := hash.Sum(nil)
+		// Get hash as a hex string
+		commit := hex.EncodeToString(commitBytes)
+		message := "Recipe " + bp.Name + ", version " + bp.Version + " saved."
+		timestamp := time.Now().Format("2006-01-02T15:04:05Z")
+		change := blueprint.Change{
+			Commit:    commit,
+			Message:   message,
+			Timestamp: timestamp,
+			Blueprint: bp,
+		}
+
 		delete(s.Workspace, bp.Name)
+		if s.BlueprintsChanges[bp.Name] == nil {
+			s.BlueprintsChanges[bp.Name] = make(map[string]blueprint.Change)
+		}
+		s.BlueprintsChanges[bp.Name][commit] = change
 		s.Blueprints[bp.Name] = bp
 		return nil
 	})
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index 204a9b97f..2b80d959c 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -64,6 +64,7 @@ func New(repo rpmmd.RepoConfig, packages rpmmd.PackageList, logger *log.Logger,
 	api.router.GET("/api/v0/blueprints/info/*blueprints", api.blueprintsInfoHandler)
 	api.router.GET("/api/v0/blueprints/depsolve/*blueprints", api.blueprintsDepsolveHandler)
 	api.router.GET("/api/v0/blueprints/diff/:blueprint/:from/:to", api.blueprintsDiffHandler)
+	api.router.GET("/api/v0/blueprints/changes/*blueprints", api.blueprintsChangesHandler)
 	api.router.POST("/api/v0/blueprints/new", api.blueprintsNewHandler)
 	api.router.POST("/api/v0/blueprints/workspace", api.blueprintsWorkspaceHandler)
 	api.router.DELETE("/api/v0/blueprints/delete/:blueprint", api.blueprintDeleteHandler)
@@ -706,6 +707,73 @@ func (api *API) blueprintsDiffHandler(writer http.ResponseWriter, request *http.
 	json.NewEncoder(writer).Encode(reply{diffs})
 }
 
+func (api *API) blueprintsChangesHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
+	type change struct {
+		Changes []blueprint.Change `json:"changes"`
+		Name    string             `json:"name"`
+		Total   int                `json:"total"`
+	}
+
+	type reply struct {
+		BlueprintsChanges []change        `json:"blueprints"`
+		Errors            []responseError `json:"errors"`
+		Limit             uint            `json:"limit"`
+		Offset            uint            `json:"offset"`
+	}
+
+	names := strings.Split(params.ByName("blueprints"), ",")
+	if names[0] == "/" {
+		errors := responseError{
+			Code: http.StatusNotFound,
+			ID:   "HTTPError",
+			Msg:  "Not Found",
+		}
+		statusResponseError(writer, http.StatusNotFound, errors)
+		return
+	}
+
+	offset, limit, err := parseOffsetAndLimit(request.URL.Query())
+	if err != nil {
+		errors := responseError{
+			ID:  "BadLimitOrOffset",
+			Msg: fmt.Sprintf("BadRequest: %s", err.Error()),
+		}
+		statusResponseError(writer, http.StatusBadRequest, errors)
+		return
+	}
+
+	allChanges := []change{}
+	errors := []responseError{}
+	for i, name := range names {
+		// remove leading / from first name
+		if i == 0 {
+			name = name[1:]
+		}
+		bpChanges := api.store.GetBlueprintChanges(name)
+		if bpChanges != nil {
+			change := change{
+				Changes: bpChanges,
+				Name:    name,
+				Total:   len(bpChanges),
+			}
+			allChanges = append(allChanges, change)
+		} else {
+			error := responseError{
+				ID:  "UnknownBlueprint",
+				Msg: name,
+			}
+			errors = append(errors, error)
+		}
+	}
+
+	json.NewEncoder(writer).Encode(reply{
+		BlueprintsChanges: allChanges,
+		Errors:            errors,
+		Offset:            offset,
+		Limit:             limit,
+	})
+}
+
 func (api *API) blueprintsNewHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
 	contentType := request.Header["Content-Type"]
 	if len(contentType) != 1 || contentType[0] != "application/json" {


Test patch:
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index 230683ab1..f1b32e3ee 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -5,13 +5,16 @@ import (
 	"context"
 	"encoding/json"
 	"io/ioutil"
+	"math/rand"
 	"net"
 	"net/http"
 	"net/http/httptest"
 	"os"
 	"reflect"
+	"strconv"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/store"
@@ -298,6 +301,21 @@ func TestBlueprintsDelete(t *testing.T) {
 	}
 }
 
+func TestBlueprintsChanges(t *testing.T) {
+	api := weldr.New(repo, packages, nil, store.New(nil))
+	rand.Seed(time.Now().UnixNano())
+	id := strconv.Itoa(rand.Int())
+	ignoreFields := []string{"commit", "timestamp"}
+
+	sendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"`+id+`","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
+	testRoute(t, api, true, "GET", "/api/v0/blueprints/changes/failing"+id, ``, http.StatusOK, `{"blueprints":[],"errors":[{"id":"UnknownBlueprint","msg":"failing`+id+`"}],"limit":20,"offset":0}`, ignoreFields...)
+	testRoute(t, api, true, "GET", "/api/v0/blueprints/changes/"+id, ``, http.StatusOK, `{"blueprints":[{"changes":[{"commit":"","message":"Recipe `+id+`, version 0.0.0 saved.","revision":null,"timestamp":""}],"name":"`+id+`","total":1}],"errors":[],"limit":20,"offset":0}`, ignoreFields...)
+	sendHTTP(api, true, "DELETE", "/api/v0/blueprints/delete/"+id, ``)
+	sendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"`+id+`","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
+	testRoute(t, api, true, "GET", "/api/v0/blueprints/changes/"+id, ``, http.StatusOK, `{"blueprints":[{"changes":[{"commit":"","message":"Recipe `+id+`, version 0.0.0 saved.","revision":null,"timestamp":""},{"commit":"","message":"Recipe `+id+`, version 0.0.0 saved.","revision":null,"timestamp":""}],"name":"`+id+`","total":2}],"errors":[],"limit":20,"offset":0}`, ignoreFields...)
+	sendHTTP(api, true, "DELETE", "/api/v0/blueprints/delete/"+id, ``)
+}
+
 func TestCompose(t *testing.T) {
 	var cases = []struct {
 		External       bool