Skip to content

feat: add Platform Hub Version Control Settings and Process Templates - #469

Open
N-lson wants to merge 6 commits into
mainfrom
nelson/platform-hub-and-process-templates
Open

feat: add Platform Hub Version Control Settings and Process Templates#469
N-lson wants to merge 6 commits into
mainfrom
nelson/platform-hub-and-process-templates

Conversation

@N-lson

@N-lson N-lson commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

This PR adds support for:

  • Viewing Platform Hub GitHub Connections
  • Configuring the Platform Hub Version Control Settings
  • Creating and viewing Process Templates

It does not add support for modifying the GitHub Connections or Process Templates.

E2E tests haven't been added as this would require extending the E2E tests to support mocking out a GitHub connection and a local Git repository which doesn't seem worth the effort. There are unit tests.

A basic scenario has been tested using the following Go script:

package main

import (
	"bufio"
	"flag"
	"fmt"
	"net/url"
	"os"
	"strings"
	"time"

	"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client"
	"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core"
	"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials"
	"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/platformhubversioncontrolsettings"
	"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/processtemplates"
)

func main() {
	report, err := run()
	if err != nil {
		fmt.Fprintf(os.Stderr, "\nFAILED: %v\n", err)
		os.Exit(1)
	}

	report.print()
	if report.failed() {
		os.Exit(1)
	}
}

func run() (*report, error) {
	loadDotEnv(".env")

	var (
		octopusURL = flag.String("octopus-url", os.Getenv("OCTOPUS_HOST"), "Octopus instance URL (defaults to $OCTOPUS_HOST)")
		apiKey     = flag.String("api-key", os.Getenv("OCTOPUS_API_KEY"), "Octopus API key (defaults to $OCTOPUS_API_KEY)")

		gitURL          = flag.String("git-url", "", "Git repository URL for Platform Hub")
		gitUsername     = flag.String("git-username", "", "Git username (ignored when -git-connection-id is set)")
		gitPassword     = flag.String("git-password", "", "Git password or personal access token (ignored when -git-connection-id is set)")
		gitConnectionID = flag.String("git-connection-id", "", "configure version control with this GitHub App connection instead of username/password")
		gitBranch       = flag.String("git-branch", "main", "default branch")
		gitBasePath     = flag.String("git-base-path", ".octopus/", "base path within the repository")

		connectionID  = flag.String("connection-id", "", "GitHub connection to read back (defaults to the first one returned by the list route)")
		templateName  = flag.String("name", "", "process template name (defaults to a timestamped name)")
		skipConfigure = flag.Bool("skip-configure", false, "skip the version control update and use the settings already on the instance")
		assumeYes     = flag.Bool("y", false, "do not prompt before overwriting the existing version control settings")
	)
	flag.Parse()

	if *octopusURL == "" || *apiKey == "" {
		return nil, fmt.Errorf("octopus URL and API key are required; set OCTOPUS_HOST and OCTOPUS_API_KEY, or pass -octopus-url and -api-key")
	}
	usingGitHubApp := *gitConnectionID != ""
	if !*skipConfigure {
		if *gitURL == "" {
			return nil, fmt.Errorf("-git-url is required unless -skip-configure is set")
		}
		if !usingGitHubApp && (*gitUsername == "" || *gitPassword == "") {
			return nil, fmt.Errorf("-git-username and -git-password are required unless -git-connection-id or -skip-configure is set")
		}
	}

	apiURL, err := url.Parse(*octopusURL)
	if err != nil {
		return nil, fmt.Errorf("parsing Octopus URL: %w", err)
	}

	// Platform Hub is system-scoped, so no space ID is required
	octopusClient, err := client.NewClient(nil, apiURL, *apiKey, "")
	if err != nil {
		return nil, fmt.Errorf("creating API client: %w", err)
	}

	r := &report{}

	// ------------------------------------------------- githubconnections.GetSettings
	r.run("githubconnections.GetSettings", func() error {
		settings, err := githubconnections.GetSettings(octopusClient)
		if err != nil {
			return err
		}
		fmt.Printf("  canUseGitHubApp=%t canUseTrustedFlow=%t\n", settings.CanUseGitHubApp, settings.CanUseTrustedFlow)
		if !settings.CanUseGitHubApp {
			fmt.Println("  note: this instance cannot use GitHub App connections, so the connection routes below may be empty")
		}
		return nil
	})

	// ------------------------------------------------- platformhubgithubconnections.List
	var firstConnectionID string
	r.run("platformhubgithubconnections.List", func() error {
		// both skip and take are required by the API, so a zero skip is still sent
		page, err := platformhubgithubconnections.List(octopusClient, 0, 30)
		if err != nil {
			return err
		}
		fmt.Printf("  returned=%d totalResults=%d itemsPerPage=%d numberOfPages=%d\n", len(page.Connections), page.TotalResults, page.ItemsPerPage, page.NumberOfPages)
		for _, connection := range page.Connections {
			account := "<no installation>"
			if connection.Installation != nil {
				account = fmt.Sprintf("%s %s", connection.Installation.AccountType, connection.Installation.AccountLogin)
			}
			fmt.Printf("  connection: %s %s [%s]\n", connection.ID, account, connection.Status)
		}
		if len(page.Connections) > 0 {
			firstConnectionID = page.Connections[0].ID
		}
		return nil
	})

	// the by-id and repositories routes need a connection to read; prefer the flag, then
	// whatever the list route handed back
	readConnectionID := *connectionID
	if readConnectionID == "" {
		readConnectionID = firstConnectionID
	}
	// configuring through a connection implies that connection exists, so it is a usable
	// fallback when the list route returned nothing
	if readConnectionID == "" {
		readConnectionID = *gitConnectionID
	}

	// ------------------------------------------------- platformhubgithubconnections.GetByID
	if readConnectionID == "" {
		r.skip("platformhubgithubconnections.GetByID", "no connection available; pass -connection-id")
		r.skip("platformhubgithubconnections.GetRepositories", "no connection available; pass -connection-id")
	} else {
		r.run("platformhubgithubconnections.GetByID", func() error {
			connection, err := platformhubgithubconnections.GetByID(octopusClient, readConnectionID)
			if err != nil {
				return err
			}
			account := "<no installation>"
			if connection.Installation != nil {
				account = connection.Installation.AccountLogin
			}
			fmt.Printf("  id=%s account=%s status=%s %s\n", connection.ID, account, connection.Status, connection.StatusUserMessage)
			fmt.Printf("  repositories=%d unknownRepositories=%d\n", len(connection.Repositories), len(connection.UnknownRepositories))
			return nil
		})

		// ---------------------------------------- platformhubgithubconnections.GetRepositories
		r.run("platformhubgithubconnections.GetRepositories", func() error {
			repositories, err := platformhubgithubconnections.GetRepositories(octopusClient, readConnectionID)
			if err != nil {
				return err
			}
			fmt.Printf("  returned=%d\n", len(repositories))
			for _, repository := range repositories {
				fmt.Printf("  repository: %s (%s) defaultBranch=%s\n", repository.RepositoryName, repository.GitURL, repository.DefaultBranch)
			}
			return nil
		})
	}

	// ------------------------------------------------- platformhubversioncontrolsettings.Get
	// read before writing, so the run reports what it is about to overwrite
	var existing *platformhubversioncontrolsettings.Resource
	r.run("platformhubversioncontrolsettings.Get", func() error {
		settings, err := platformhubversioncontrolsettings.Get(octopusClient)
		if err != nil {
			return err
		}
		existing = settings
		printSettings("  current", settings)
		return nil
	})

	branch := *gitBranch

	// ------------------------------------------------- platformhubversioncontrolsettings.Update
	switch {
	case *skipConfigure:
		r.skip("platformhubversioncontrolsettings.Update", "-skip-configure was set")
		if existing == nil || existing.URL == "" {
			return r, fmt.Errorf("-skip-configure was set but Platform Hub is not configured for version control; cannot exercise the process template routes")
		}
		branch = existing.DefaultBranch
	default:
		if existing != nil && existing.URL != "" && existing.URL != *gitURL && !*assumeYes {
			fmt.Printf("\nPlatform Hub is currently pointed at %s.\nThis will repoint it at %s. Continue? [y/N] ", existing.URL, *gitURL)
			if !confirmed() {
				return r, fmt.Errorf("aborted")
			}
		}

		var gitCredentials credentials.GitCredential
		if usingGitHubApp {
			gitCredentials = credentials.NewGitHubApp(*gitConnectionID)
		} else {
			gitCredentials = credentials.NewUsernamePassword(*gitUsername, core.NewSensitiveValue(*gitPassword))
		}

		r.run("platformhubversioncontrolsettings.Update", func() error {
			settings := platformhubversioncontrolsettings.NewResource(*gitURL, gitCredentials, branch, *gitBasePath)
			updated, err := platformhubversioncontrolsettings.Update(octopusClient, settings)
			if err != nil {
				return err
			}
			printSettings("  updated", updated)

			// read back, so deserialisation of what the server actually stored is exercised
			// rather than just the echo from the PUT. This is the path the GitHubApp
			// UnmarshalJSON fix unblocked.
			reread, err := platformhubversioncontrolsettings.Get(octopusClient)
			if err != nil {
				return fmt.Errorf("re-reading settings after update: %w", err)
			}
			printSettings("  re-read", reread)

			if reread.Credentials == nil {
				return fmt.Errorf("settings were saved but read back with nil credentials")
			}
			return nil
		})
	}

	gitRef := branch
	if !strings.HasPrefix(gitRef, "refs/") {
		gitRef = "refs/heads/" + gitRef
	}
	fmt.Printf("\nusing gitRef=%s for the process template routes\n\n", gitRef)

	// ------------------------------------------------- processtemplates.Add
	name := *templateName
	if name == "" {
		// a unique name per run, so repeated runs do not collide on slug
		name = fmt.Sprintf("manual-test-%s", time.Now().Format("20060102-150405"))
	}

	var createdSlug string
	var createdName string
	r.run("processtemplates.Add", func() error {
		created, err := processtemplates.Add(
			octopusClient,
			gitRef,
			name,
			"Created by manualtest/platformhub",
			fmt.Sprintf("Add process template %s", name),
		)
		if err != nil {
			return err
		}
		fmt.Printf("  id=%s slug=%s name=%s gitRef=%s\n", created.ID, created.Slug, created.Name, created.GitRef)
		if created.Slug == "" {
			return fmt.Errorf("the server did not return a slug for the created process template")
		}
		createdSlug = created.Slug
		createdName = created.Name
		return nil
	})

	// ------------------------------------------------- processtemplates.List
	r.run("processtemplates.List", func() error {
		results, err := processtemplates.List(octopusClient, processtemplates.ProcessTemplatesQuery{
			GitRef: gitRef,
			Take:   30,
		})
		if err != nil {
			return err
		}
		fmt.Printf("  returned=%d totalResults=%d itemsPerPage=%d\n", len(results.ProcessTemplates), results.TotalResults, results.ItemsPerPage)
		for _, processTemplate := range results.ProcessTemplates {
			fmt.Printf("  process template: %s (%s) steps=%d parameters=%d\n", processTemplate.Name, processTemplate.Slug, len(processTemplate.Steps), len(processTemplate.Parameters))
		}
		return nil
	})

	// ------------------------------------------------- processtemplates.GetBySlug
	if createdSlug == "" {
		r.skip("processtemplates.GetBySlug", "no slug available because the create failed")
	} else {
		r.run("processtemplates.GetBySlug", func() error {
			fetched, err := processtemplates.GetBySlug(octopusClient, gitRef, createdSlug)
			if err != nil {
				return err
			}
			fmt.Printf("  id=%s slug=%s name=%s\n", fetched.ID, fetched.Slug, fetched.Name)
			fmt.Printf("  description=%s\n", fetched.Description)
			fmt.Printf("  steps=%d parameters=%d\n", len(fetched.Steps), len(fetched.Parameters))

			for _, step := range fetched.Steps {
				fmt.Printf("  step: %s\n", step.Name)
			}
			for _, parameter := range fetched.Parameters {
				fmt.Printf("  parameter: %s optional=%t\n", parameter.Name, parameter.IsOptional)
				for _, value := range parameter.Values {
					if value.Value.IsSensitive {
						fmt.Println("    default value: (sensitive)")
						continue
					}
					fmt.Printf("    default value: %s\n", value.Value.Value)
				}
			}

			if fetched.Name != createdName {
				return fmt.Errorf("round trip mismatch: created %q but fetched %q", createdName, fetched.Name)
			}
			return nil
		})
	}

	return r, nil
}

func printSettings(label string, settings *platformhubversioncontrolsettings.Resource) {
	if settings.URL == "" {
		fmt.Printf("%s: Platform Hub is not configured for version control\n", label)
		return
	}

	fmt.Printf("%s: url=%s branch=%s basePath=%s\n", label, settings.URL, settings.DefaultBranch, settings.BasePath)

	switch creds := settings.Credentials.(type) {
	case *credentials.Anonymous:
		fmt.Printf("%s: credentials=anonymous\n", label)
	case *credentials.UsernamePassword:
		fmt.Printf("%s: credentials=username/password (%s)\n", label, creds.Username)
	case *credentials.GitHubApp:
		fmt.Printf("%s: credentials=GitHub App connection (%s)\n", label, creds.ID)
	case nil:
		// a nil here against a configured instance means the credential type came back as
		// something UnmarshalJSON does not handle - a reference credential, or an SSH key
		fmt.Printf("%s: credentials=<nil> (unhandled credential type?)\n", label)
	default:
		fmt.Printf("%s: credentials=%T\n", label, creds)
	}
}

// report records the outcome of each function under test.
type report struct {
	entries []entry
}

type entry struct {
	name   string
	status string
	detail string
}

const (
	statusPass = "PASS"
	statusFail = "FAIL"
	statusSkip = "SKIP"
)

func (r *report) run(name string, fn func() error) {
	fmt.Printf("== %s ==\n", name)
	if err := fn(); err != nil {
		fmt.Printf("  ERROR: %v\n\n", err)
		r.entries = append(r.entries, entry{name: name, status: statusFail, detail: err.Error()})
		return
	}
	fmt.Println()
	r.entries = append(r.entries, entry{name: name, status: statusPass})
}

func (r *report) skip(name string, reason string) {
	fmt.Printf("== %s ==\n  SKIPPED: %s\n\n", name, reason)
	r.entries = append(r.entries, entry{name: name, status: statusSkip, detail: reason})
}

func (r *report) failed() bool {
	for _, e := range r.entries {
		if e.status == statusFail {
			return true
		}
	}
	return false
}

func (r *report) print() {
	fmt.Println("== summary ==")

	width := 0
	for _, e := range r.entries {
		if len(e.name) > width {
			width = len(e.name)
		}
	}

	skipped := 0
	for _, e := range r.entries {
		fmt.Printf("%-4s %-*s %s\n", e.status, width, e.name, e.detail)
		if e.status == statusSkip {
			skipped++
		}
	}

	if skipped > 0 {
		fmt.Printf("\n%d function(s) were skipped and have NOT been tested by this run\n", skipped)
	}
}

func confirmed() bool {
	scanner := bufio.NewScanner(os.Stdin)
	if !scanner.Scan() {
		return false
	}
	answer := strings.ToLower(strings.TrimSpace(scanner.Text()))
	return answer == "y" || answer == "yes"
}

// loadDotEnv reads KEY=VALUE lines from path into the environment, without overwriting
// variables that are already set. Missing or malformed files are ignored.
func loadDotEnv(path string) {
	file, err := os.Open(path)
	if err != nil {
		return
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}

		key, value, found := strings.Cut(line, "=")
		if !found {
			continue
		}

		key = strings.TrimSpace(key)
		value = strings.Trim(strings.TrimSpace(value), "\"'")
		if _, alreadySet := os.LookupEnv(key); !alreadySet {
			_ = os.Setenv(key, value)
		}
	}
}

@gitguardian

gitguardian Bot commented Aug 26, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@N-lson

N-lson commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

The secret mentioned above ^ is a fake sensitive variable used in a test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant