package pulp import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "sync" "time" "github.com/osbuild/pulp-client/pulpclient" "github.com/sirupsen/logrus" ) type Client struct { client *pulpclient.APIClient ctx context.Context } type Credentials struct { Username string `json:"username"` Password string `json:"password"` } func NewClientFromFile(url, path string) (*Client, error) { fp, err := os.Open(path) if err != nil { return nil, err } defer fp.Close() data, err := io.ReadAll(fp) if err != nil { return nil, err } var creds Credentials if err := json.Unmarshal(data, &creds); err != nil { return nil, err } return NewClient(url, &creds), nil } func NewClient(url string, creds *Credentials) *Client { ctx := context.WithValue(context.Background(), pulpclient.ContextServerIndex, 0) transport := &http.Transport{} httpClient := http.Client{Transport: transport} pulpConfig := pulpclient.NewConfiguration() pulpConfig.HTTPClient = &httpClient pulpConfig.Servers = pulpclient.ServerConfigurations{pulpclient.ServerConfiguration{ URL: url, }} client := pulpclient.NewAPIClient(pulpConfig) if creds != nil { ctx = context.WithValue(ctx, pulpclient.ContextBasicAuth, pulpclient.BasicAuth{ UserName: creds.Username, Password: creds.Password, }) } return &Client{ client: client, ctx: ctx, } } // readBody returns the body of a response as a string and ignores // errors. Useful for returning details from failed requests. func readBody(r *http.Response) string { if r == nil { return "" } b, err := io.ReadAll(r.Body) if err != nil { return "" } return string(b) } // UploadFile uploads the file at the given path and returns the href of the // new artifact. func (cl *Client) UploadFile(path string) (string, error) { fp, err := os.Open(path) if err != nil { return "", err } defer fp.Close() create := cl.client.ArtifactsAPI.ArtifactsCreate(cl.ctx).File(fp) res, resp, err := create.Execute() if err != nil { return "", fmt.Errorf("failed to upload file %q: %s (%s)", path, err.Error(), readBody(resp)) } return res.GetPulpHref(), nil } // GetOSTreeRepositoryByName returns the href for a repository based on its name. // Returns an empty string without an error if the repository does not exist. func (cl *Client) GetOSTreeRepositoryByName(name string) (string, error) { list, resp, err := cl.client.RepositoriesOstreeAPI.RepositoriesOstreeOstreeList(cl.ctx).Name(name).Execute() if err != nil { return "", fmt.Errorf("repository list request returned an error: %s (%s)", err.Error(), readBody(resp)) } if list.GetCount() == 0 { logrus.Infof("no repository named %s was found", name) return "", nil } if list.GetCount() > 1 { return "", fmt.Errorf("more than one repository named %s was found: %s (%s)", name, err.Error(), readBody(resp)) } results := list.GetResults() repo := results[0] repoHref := repo.GetPulpHref() logrus.Infof("found repository %s: %s", name, repoHref) return repoHref, nil } // ListOSTreeRepositories returns a map (repository name -> pulp href) of // existing ostree repositories. func (cl *Client) ListOSTreeRepositories() (map[string]string, error) { list, resp, err := cl.client.RepositoriesOstreeAPI.RepositoriesOstreeOstreeList(cl.ctx).Execute() if err != nil { return nil, fmt.Errorf("repository list request returned an error: %s (%s)", err.Error(), readBody(resp)) } repos := make(map[string]string, list.GetCount()) for _, repo := range list.GetResults() { name := repo.Name href := repo.GetPulpHref() repos[name] = href } return repos, nil } // CreateOSTreeRepository creates a new ostree repository with a name and description // and returns the pulp href. func (cl *Client) CreateOSTreeRepository(name, description string) (string, error) { req := cl.client.RepositoriesOstreeAPI.RepositoriesOstreeOstreeCreate(cl.ctx) repo := pulpclient.OstreeOstreeRepository{ Name: name, } if description != "" { repo.Description = *pulpclient.NewNullableString(&description) } req = req.OstreeOstreeRepository(repo) result, resp, err := req.Execute() if err != nil { return "", fmt.Errorf("repository creation failed: %s (%s)", err.Error(), readBody(resp)) } return result.GetPulpHref(), nil } // ImportCommit imports a commit that has already been uploaded to a given // repository. The commitHref must reference a commit tarball artifact. This // task is asynchronous. The returned value is the href for the import task. func (cl *Client) ImportCommit(commitHref, repoHref string) (string, error) { req := cl.client.RepositoriesOstreeAPI.RepositoriesOstreeOstreeImportAll(cl.ctx, repoHref) importOptions := *pulpclient.NewOstreeImportAll(commitHref, "repo") // our commit archives always use the repo name "repo" result, resp, err := req.OstreeImportAll(importOptions).Execute() if err != nil { return "", fmt.Errorf("ostree commit import failed: %s (%s)", err.Error(), readBody(resp)) } return result.Task, nil } // Distribute makes an ostree repository available for download. This task is // asynchronous. The returned value is the href for the distribute task. func (cl *Client) DistributeOSTreeRepo(basePath, name, repoHref string) (string, error) { dist := *pulpclient.NewOstreeOstreeDistribution(basePath, name) dist.SetRepository(repoHref) res, resp, err := cl.client.DistributionsOstreeAPI.DistributionsOstreeOstreeCreate(cl.ctx).OstreeOstreeDistribution(dist).Execute() if err != nil { return "", fmt.Errorf("error distributing ostree repository: %s (%s)", err.Error(), readBody(resp)) } return res.Task, nil } // GetDistributionURLForOSTreeRepo returns the basepath for an ostree // distribution of a given repository. Returns an empty string without an error // if the no distribution for the repo exists. func (cl *Client) GetDistributionURLForOSTreeRepo(repoHref string) (string, error) { list, resp, err := cl.client.DistributionsOstreeAPI.DistributionsOstreeOstreeList(cl.ctx).Repository(repoHref).Execute() if list.GetCount() == 0 { logrus.Infof("no distribution for repo %s was found: %s", repoHref, readBody(resp)) return "", nil } if err != nil { return "", fmt.Errorf("error looking up distribution for repo %s: %s (%s)", repoHref, err.Error(), readBody(resp)) } results := list.GetResults() // if there's more than one distribution, return the first one dist := results[0] return dist.GetBaseUrl(), nil } type TaskState string const ( TASK_WAITING TaskState = "waiting" TASK_SKIPPED TaskState = "skipped" TASK_RUNNING TaskState = "running" TASK_COMPLETED TaskState = "completed" TASK_FAILED TaskState = "failed" TASK_CANCELED TaskState = "canceled" TASK_CANCELING TaskState = "canceling" ) // TaskState returns the state of a given task. func (cl *Client) TaskState(task string) (TaskState, error) { res, resp, err := cl.client.TasksAPI.TasksRead(cl.ctx, task).Execute() if err != nil { return "", fmt.Errorf("error reading task %s: %s (%s)", task, err.Error(), readBody(resp)) } state := res.GetState() if state == "" { return "", fmt.Errorf("got empty task state for %s", task) } return TaskState(state), nil } // TaskWaitingOrRunning returns true if the given task is in the running state. Errors // are ignored and return false. func (cl *Client) TaskWaitingOrRunning(task string) bool { state, err := cl.TaskState(task) if err != nil { // log the error and return false logrus.Errorf("failed to get task state: %s", err.Error()) return false } return state == TASK_RUNNING || state == TASK_WAITING } // Block until all tasks are complete. func (cl *Client) waitFor(tasks []string) { wg := sync.WaitGroup{} logrus.Info("setting up wait group") for idx := range tasks { task := tasks[idx] logrus.Infof("task %q", task) wg.Add(1) go func() { logrus.Infof("waiting for task %q to finish", task) for cl.TaskWaitingOrRunning(task) { time.Sleep(15 * time.Second) } logrus.Infof("task %q done", task) wg.Done() }() } wg.Wait() } // UploadAndDistributeCommit uploads a commit, creates a repository if // necessary, imports the commit to the repository, and distributes the // repository. func (cl *Client) UploadAndDistributeCommit(archivePath, repoName, basePath string) (string, error) { // Check for the repository before uploading the commit: // If the repository needs to be created but the basePath is empty, we // should fail before uploading the commit. logrus.Infof("checking if repository %q already exists", repoName) repoHref, err := cl.GetOSTreeRepositoryByName(repoName) if err != nil { return "", err } if repoHref == "" && basePath == "" { return "", fmt.Errorf("repository %q does not exist and needs to be created, but no basepath for distribution was provided", repoName) } // Upload the file before creating the repository (if we need to create it) // in case it fails. We don't want to have an empty repository if the // commit upload fails. logrus.Infof("uploading ostree commit to pulp") fileHref, err := cl.UploadFile(archivePath) if err != nil { return "", err } tasks := make([]string, 0) if repoHref == "" { // repository does not exist: create it and distribute logrus.Infof("repository not found - creating repository %q", repoName) href, err := cl.CreateOSTreeRepository(repoName, "") if err != nil { return "", err } repoHref = href logrus.Infof("created repository %q (%s)", repoName, repoHref) logrus.Infof("creating distribution at %q", basePath) distTask, err := cl.DistributeOSTreeRepo(basePath, repoName, repoHref) if err != nil { return "", err } tasks = append(tasks, distTask) } logrus.Infof("importing commit %q to repo %q", fileHref, repoHref) importTask, err := cl.ImportCommit(fileHref, repoHref) if err != nil { return "", err } tasks = append(tasks, importTask) logrus.Infof("blocking on %d tasks", len(tasks)) cl.waitFor(tasks) repoURL, err := cl.GetDistributionURLForOSTreeRepo(repoHref) if err != nil { return "", err } logrus.Infof("repository url: %s", repoURL) return repoURL, nil }