├── .github ├── CODEOWNERS └── workflows │ ├── go.yml │ ├── pr.yml │ ├── release.yml │ └── airbox-release.yml ├── .gitignore ├── imgs └── arch.png ├── internal ├── service │ ├── testdata │ │ └── test-edition.values.yaml │ ├── mock_test.go │ ├── uninstall.go │ ├── status.go │ └── manager_test.go ├── cmd │ ├── local │ │ ├── testdata │ │ │ ├── invalid.values.yaml │ │ │ └── expected-default.values.yaml │ │ ├── deployments.go │ │ ├── local.go │ │ ├── status.go │ │ ├── uninstall.go │ │ ├── deployments_test.go │ │ └── credentials.go │ ├── images │ │ ├── images_cmd.go │ │ ├── testdata │ │ │ └── enterprise.values.yaml │ │ └── manifest_cmd.go │ ├── delete │ │ ├── delete.go │ │ ├── dataplane.go │ │ └── dataplane_test.go │ ├── install │ │ └── install.go │ ├── get │ │ ├── get.go │ │ └── dataplane.go │ ├── config │ │ ├── config.go │ │ └── init.go │ ├── auth │ │ ├── auth.go │ │ ├── logout.go │ │ ├── logout_test.go │ │ └── switch-organization.go │ ├── version │ │ ├── version.go │ │ └── version_test.go │ ├── cmd.go │ ├── output.go │ └── output_test.go ├── http │ ├── default.go │ ├── client.go │ └── mock │ │ └── mock.go ├── validate │ ├── url.go │ └── url_test.go ├── ui │ ├── yaml.go │ ├── json.go │ ├── spinner.go │ ├── spinner_test.go │ ├── select.go │ ├── textinput.go │ ├── json_test.go │ └── yaml_test.go ├── telemetry │ ├── dnt.go │ ├── noop.go │ ├── mock.go │ ├── dnt_test.go │ ├── noop_test.go │ ├── client_test.go │ └── client.go ├── helm │ ├── factory.go │ ├── nginx_values.go │ ├── meta.go │ ├── helm.go │ ├── meta_test.go │ ├── dataplane.go │ ├── images.go │ ├── locate.go │ └── chart.go ├── docker │ ├── secret_test.go │ └── secret.go ├── k8s │ ├── cluster_test.go │ ├── factory.go │ ├── log.go │ ├── volumes.go │ ├── namespace.go │ ├── log_test.go │ ├── volumes_test.go │ ├── kind │ │ └── config.go │ ├── provider_test.go │ ├── mock │ │ └── cluster.go │ ├── provider.go │ └── ingress.go ├── common │ ├── set.go │ └── const.go ├── abctl │ ├── error_test.go │ └── error.go ├── pgdata │ └── version.go ├── api │ ├── client_test.go │ ├── client.go │ ├── organizations.go │ └── regions.go ├── airbox │ ├── credentials.go │ ├── env.go │ ├── config_validations.go │ ├── config_store.go │ ├── factories.go │ ├── credentials_test.go │ └── mock │ │ └── config.go ├── airbyte │ ├── log_scanner_test.go │ └── log_scanner.go ├── merge │ └── docker.go ├── paths │ ├── paths.go │ └── paths_test.go ├── update │ ├── update.go │ └── update_test.go ├── build │ ├── build.go │ └── build_test.go ├── maps │ └── maps.go └── auth │ ├── auth_test.go │ └── mocks_creds_test.go ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── cmd └── airbox │ └── main.go └── main.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @airbytehq/platform-deployments 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | /build 4 | dist/ 5 | /abctl 6 | -------------------------------------------------------------------------------- /imgs/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbytehq/abctl/HEAD/imgs/arch.png -------------------------------------------------------------------------------- /internal/service/testdata/test-edition.values.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | edition: test -------------------------------------------------------------------------------- /internal/cmd/local/testdata/invalid.values.yaml: -------------------------------------------------------------------------------- 1 | foo: 2 | - bar: baz 3 | - foo -------------------------------------------------------------------------------- /internal/cmd/images/images_cmd.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | type Cmd struct { 4 | Manifest ManifestCmd `cmd:"" help:"Display a manifest of images used by Airbyte and abctl."` 5 | } 6 | -------------------------------------------------------------------------------- /internal/cmd/delete/delete.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | // Cmd represents the delete command group. 4 | type Cmd struct { 5 | Dataplane DataplaneCmd `cmd:"" help:"Delete a dataplane."` 6 | } 7 | -------------------------------------------------------------------------------- /internal/cmd/install/install.go: -------------------------------------------------------------------------------- 1 | package install 2 | 3 | // Cmd represents the install command group 4 | type Cmd struct { 5 | Dataplane DataplaneCmd `cmd:"" help:"Install a new dataplane."` 6 | } 7 | -------------------------------------------------------------------------------- /internal/cmd/get/get.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | // Cmd represents the get command group. 4 | type Cmd struct { 5 | Dataplane DataplaneCmd `cmd:"" aliases:"dataplanes" help:"Get dataplane details."` 6 | } 7 | -------------------------------------------------------------------------------- /internal/cmd/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Cmd represents the config command group 4 | type Cmd struct { 5 | Init InitCmd `cmd:"" help:"Initialize abctl configuration from existing Airbyte installation."` 6 | } -------------------------------------------------------------------------------- /internal/http/default.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | // DefaultClient is the default HTTP client with reasonable timeout 9 | var DefaultClient = &http.Client{ 10 | Timeout: 30 * time.Second, 11 | } -------------------------------------------------------------------------------- /internal/validate/url.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import "net/url" 4 | 5 | func IsURL(s string) bool { 6 | u, err := url.Parse(s) 7 | if err != nil { 8 | return false 9 | } 10 | return (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" 11 | } 12 | -------------------------------------------------------------------------------- /internal/service/mock_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | var _ HTTPClient = (*mockHTTP)(nil) 8 | 9 | type mockHTTP struct { 10 | do func(req *http.Request) (*http.Response, error) 11 | } 12 | 13 | func (m *mockHTTP) Do(req *http.Request) (*http.Response, error) { 14 | return m.do(req) 15 | } 16 | -------------------------------------------------------------------------------- /internal/cmd/images/testdata/enterprise.values.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | airbyteUrl: "http://localhost:8000" 3 | edition: "enterprise" 4 | 5 | auth: 6 | enabled: false 7 | instanceAdmin: 8 | firstName: "test" 9 | lastName: "user" 10 | 11 | keycloak: 12 | auth: 13 | adminUsername: airbyteAdmin 14 | adminPassword: keycloak123 -------------------------------------------------------------------------------- /internal/ui/yaml.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | // ShowYAML displays formatted YAML output 10 | func (ui *BubbleteaUI) ShowYAML(data any) error { 11 | yamlData, err := yaml.Marshal(data) 12 | if err != nil { 13 | return fmt.Errorf("failed to marshal YAML: %w", err) 14 | } 15 | fmt.Fprint(ui.stdout, string(yamlData)) 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /internal/telemetry/dnt.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import "os" 4 | 5 | // envVarDNT is the environment variable used to determine if dnt mode is enabled 6 | const envVarDNT = "DO_NOT_TRACK" 7 | 8 | // DNT returns the status of the DO_NOT_TRACK flag 9 | // If this flag is enabled, no telemetry data will be collected 10 | func DNT() bool { 11 | _, ok := os.LookupEnv(envVarDNT) 12 | return ok 13 | } 14 | -------------------------------------------------------------------------------- /internal/cmd/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | // Cmd represents the auth command group 4 | type Cmd struct { 5 | Login LoginCmd `cmd:"" help:"Login to Airbyte using OAuth."` 6 | Logout LogoutCmd `cmd:"" help:"Logout and clear stored credentials."` 7 | SwitchOrganization SwitchOrganizationCmd `cmd:"" aliases:"switch" help:"Switch to a different organization."` 8 | } 9 | -------------------------------------------------------------------------------- /internal/ui/json.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // ShowJSON displays formatted JSON output 9 | func (ui *BubbleteaUI) ShowJSON(data any) error { 10 | jsonData, err := json.MarshalIndent(data, "", " ") 11 | if err != nil { 12 | return fmt.Errorf("failed to marshal JSON: %w", err) 13 | } 14 | fmt.Fprintln(ui.stdout, string(jsonData)) 15 | return nil 16 | } 17 | -------------------------------------------------------------------------------- /internal/helm/factory.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | goHelm "github.com/mittwald/go-helm-client" 5 | ) 6 | 7 | // Factory creates helm clients 8 | type Factory func(kubeConfig, kubeContext, namespace string) (goHelm.Client, error) 9 | 10 | // DefaultFactory creates a helm client 11 | func DefaultFactory(kubeConfig, kubeContext, namespace string) (goHelm.Client, error) { 12 | return New(kubeConfig, kubeContext, namespace) 13 | } 14 | -------------------------------------------------------------------------------- /internal/cmd/local/testdata/expected-default.values.yaml: -------------------------------------------------------------------------------- 1 | airbyte-bootloader: 2 | env_vars: 3 | PLATFORM_LOG_FORMAT: json 4 | global: 5 | auth: 6 | enabled: true 7 | env_vars: 8 | AIRBYTE_INSTALLATION_ID: test-user 9 | jobs: 10 | resources: 11 | limits: 12 | cpu: "3" 13 | memory: 4Gi 14 | storage: 15 | type: local 16 | postgresql: 17 | image: 18 | tag: 1.7.0-17 19 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: "Test" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: 'stable' 19 | 20 | - name: Build 21 | run: make build 22 | 23 | - name: Test 24 | run: make test 25 | 26 | -------------------------------------------------------------------------------- /internal/docker/secret_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_Secret(t *testing.T) { 8 | exp := `{"auths":{"my-registry.example:5000":{"auth":"dGlnZXI6cGFzczEyMzQ=","email":"tiger@acme.example","password":"pass1234","username":"tiger"}}}` 9 | act, err := Secret("my-registry.example:5000", "tiger", "pass1234", "tiger@acme.example") 10 | if err != nil { 11 | t.Fatal(err) 12 | } 13 | if exp != string(act) { 14 | t.Errorf("Secret mismatch:\nwant: %s\ngot: %s", exp, act) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/k8s/cluster_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | 8 | "sigs.k8s.io/kind/pkg/exec" 9 | ) 10 | 11 | func TestFormatKindError(t *testing.T) { 12 | inner := errors.New("inner") 13 | runerr := exec.RunError{Command: []string{"one", "two"}, Output: []byte("three"), Inner: inner} 14 | str := fmt.Errorf("four: %w", formatKindErr(&runerr)).Error() 15 | expect := `four: command "one two" failed with error: inner: three` 16 | if str != expect { 17 | t.Errorf("expected %q but got %q", expect, str) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /internal/common/set.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type Set[T comparable] struct { 4 | vals map[T]struct{} 5 | } 6 | 7 | func (s *Set[T]) Add(v T) { 8 | if s.vals == nil { 9 | s.vals = map[T]struct{}{} 10 | } 11 | s.vals[v] = struct{}{} 12 | } 13 | 14 | func (s *Set[T]) Contains(v T) bool { 15 | if s.vals == nil { 16 | return false 17 | } 18 | _, ok := s.vals[v] 19 | return ok 20 | } 21 | 22 | func (s *Set[T]) Len() int { 23 | return len(s.vals) 24 | } 25 | 26 | func (s *Set[T]) Items() []T { 27 | out := make([]T, len(s.vals)) 28 | for k := range s.vals { 29 | out = append(out, k) 30 | } 31 | return out 32 | } 33 | -------------------------------------------------------------------------------- /internal/k8s/factory.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ClusterFactory creates k8s clusters 9 | type ClusterFactory func(ctx context.Context, clusterName string) (Cluster, error) 10 | 11 | // DefaultClusterFactory creates a default cluster for the given cluster name 12 | func DefaultClusterFactory(ctx context.Context, clusterName string) (Cluster, error) { 13 | provider := Provider{ 14 | Name: Kind, 15 | ClusterName: clusterName, 16 | Context: fmt.Sprintf("kind-%s", clusterName), 17 | Kubeconfig: DefaultProvider.Kubeconfig, 18 | } 19 | return provider.Cluster(ctx) 20 | } 21 | -------------------------------------------------------------------------------- /internal/k8s/log.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pterm/pterm" 7 | "k8s.io/client-go/rest" 8 | ) 9 | 10 | var _ rest.WarningHandler = (*Logger)(nil) 11 | 12 | // Logger is an implementation of the WarningHandler that converts the k8s warning messages 13 | // into abctl debug messages. 14 | type Logger struct { 15 | } 16 | 17 | func (x Logger) HandleWarningHeader(code int, _ string, msg string) { 18 | // code and length check are taken from the default WarningLogger implementation 19 | if code != 299 || len(msg) == 0 { 20 | return 21 | } 22 | pterm.Debug.Println(fmt.Sprintf("k8s - WARN: %s", msg)) 23 | } 24 | -------------------------------------------------------------------------------- /internal/abctl/error_test.go: -------------------------------------------------------------------------------- 1 | package abctl 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestLocalError(t *testing.T) { 11 | f := func() error { 12 | return &Error{ 13 | help: "help message", 14 | msg: "error message", 15 | } 16 | } 17 | 18 | err := f() 19 | var e *Error 20 | if !errors.As(err, &e) { 21 | t.Fatal("error should be of type LocalError") 22 | } 23 | 24 | if d := cmp.Diff("help message", e.Help()); d != "" { 25 | t.Errorf("help message diff:\n%s", d) 26 | } 27 | if d := cmp.Diff("error message", e.Error()); d != "" { 28 | t.Errorf("error message diff:\n%s", d) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/cmd/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/airbytehq/abctl/internal/build" 8 | "github.com/pterm/pterm" 9 | ) 10 | 11 | type Cmd struct{} 12 | 13 | func (c *Cmd) Run() error { 14 | parts := []string{fmt.Sprintf("version: %s", build.Version)} 15 | if build.Revision != "" { 16 | parts = append(parts, fmt.Sprintf("revision: %s", build.Revision)) 17 | } 18 | if build.ModificationTime != "" { 19 | parts = append(parts, fmt.Sprintf("time: %s", build.ModificationTime)) 20 | } 21 | if build.Modified { 22 | parts = append(parts, fmt.Sprintf("modified: %t", build.Modified)) 23 | } 24 | pterm.Println(strings.Join(parts, "\n")) 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/telemetry/noop.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | var _ Client = (*NoopClient)(nil) 8 | 9 | // NoopClient client, all methods are no-ops. 10 | type NoopClient struct { 11 | } 12 | 13 | func (n NoopClient) Start(context.Context, EventType) error { 14 | return nil 15 | } 16 | 17 | func (n NoopClient) Success(context.Context, EventType) error { 18 | return nil 19 | } 20 | 21 | func (n NoopClient) Failure(context.Context, EventType, error) error { 22 | return nil 23 | } 24 | 25 | func (n NoopClient) Attr(_, _ string) {} 26 | 27 | func (n NoopClient) User() string { 28 | return "" 29 | } 30 | 31 | func (n NoopClient) Wrap(ctx context.Context, et EventType, f func() error) error { 32 | return f() 33 | } 34 | -------------------------------------------------------------------------------- /internal/helm/nginx_values.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "text/template" 7 | ) 8 | 9 | var nginxValuesTpl = template.Must(template.New("nginx-values").Parse(` 10 | controller: 11 | hostPort: 12 | enabled: true 13 | service: 14 | type: NodePort 15 | ports: 16 | http: {{ .Port }} 17 | httpsPort: 18 | enable: false 19 | config: 20 | proxy-body-size: 10m 21 | proxy-read-timeout: "600" 22 | proxy-send-timeout: "600" 23 | `)) 24 | 25 | func BuildNginxValues(port int) (string, error) { 26 | var buf bytes.Buffer 27 | err := nginxValuesTpl.Execute(&buf, map[string]any{"Port": port}) 28 | if err != nil { 29 | return "", fmt.Errorf("failed to build nginx values yaml: %w", err) 30 | } 31 | return buf.String(), nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/pgdata/version.go: -------------------------------------------------------------------------------- 1 | package pgdata 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "strings" 8 | ) 9 | 10 | // Client for performing PGDATA actions. 11 | type Client struct { 12 | cfg Config 13 | } 14 | 15 | // Config stores the config needed to interact with PGDATA. 16 | type Config struct { 17 | Path string 18 | } 19 | 20 | // New returns an initialized PGDATA client. 21 | func New(cfg *Config) *Client { 22 | return &Client{ 23 | cfg: *cfg, 24 | } 25 | } 26 | 27 | // Version is returned for the PGDATA dir. 28 | func (c *Client) Version() (string, error) { 29 | versionFile := path.Join(c.cfg.Path, "PG_VERSION") 30 | b, err := os.ReadFile(versionFile) 31 | if err != nil { 32 | return "", fmt.Errorf("error reading pgdata version file: %w", err) 33 | } 34 | 35 | return strings.TrimRight(string(b), "\n"), nil 36 | } 37 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | 3 | version: 2 4 | 5 | project_name: abctl 6 | 7 | before: 8 | hooks: 9 | - go mod tidy 10 | 11 | builds: 12 | - env: 13 | - CGO_ENABLED=0 14 | goos: 15 | - linux 16 | - windows 17 | - darwin 18 | goarch: 19 | - amd64 20 | - arm64 21 | ldflags: 22 | - "-w -X github.com/airbytehq/abctl/internal/build.Version={{.Tag}}" 23 | 24 | archives: 25 | - format: tar.gz 26 | name_template: '{{ .ProjectName }}-{{ .Tag }}-{{ .Os }}-{{ .Arch }}' 27 | wrap_in_directory: true 28 | # use zip for windows archives 29 | format_overrides: 30 | - goos: windows 31 | format: zip 32 | 33 | changelog: 34 | sort: asc 35 | filters: 36 | exclude: 37 | - "^docs:" 38 | - "^test:" 39 | -------------------------------------------------------------------------------- /internal/api/client_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/airbytehq/abctl/internal/http/mock" 8 | "github.com/stretchr/testify/assert" 9 | "go.uber.org/mock/gomock" 10 | ) 11 | 12 | // errorReader simulates io.ReadAll failure for testing 13 | type errorReader struct{} 14 | 15 | // Read just simulates an error return 16 | func (e *errorReader) Read(p []byte) (n int, err error) { 17 | return 0, errors.New("read error") 18 | } 19 | 20 | // Close the error reader 21 | func (e *errorReader) Close() error { 22 | return nil 23 | } 24 | 25 | func TestNewClient(t *testing.T) { 26 | ctrl := gomock.NewController(t) 27 | defer ctrl.Finish() 28 | 29 | mockDoer := mock.NewMockHTTPDoer(ctrl) 30 | client := NewClient(mockDoer) 31 | 32 | assert.NotNil(t, client) 33 | assert.Equal(t, mockDoer, client.http) 34 | } 35 | -------------------------------------------------------------------------------- /internal/service/uninstall.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/airbytehq/abctl/internal/paths" 9 | "github.com/pterm/pterm" 10 | ) 11 | 12 | type UninstallOpts struct { 13 | Persisted bool 14 | } 15 | 16 | // Uninstall handles the uninstallation of Airbyte. 17 | func (m *Manager) Uninstall(_ context.Context, opts UninstallOpts) error { 18 | // check if persisted data should be removed, if not this is a noop 19 | if opts.Persisted { 20 | m.spinner.UpdateText("Removing persisted data") 21 | if err := os.RemoveAll(paths.Data); err != nil { 22 | pterm.Error.Println(fmt.Sprintf("Unable to remove persisted data '%s'", paths.Data)) 23 | return fmt.Errorf("unable to remove persisted data '%s': %w", paths.Data, err) 24 | } 25 | pterm.Success.Println("Removed persisted data") 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /internal/common/const.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | const ( 4 | AirbyteBootloaderPodName = "airbyte-abctl-airbyte-bootloader" 5 | AirbyteChartName = "airbyte/airbyte" 6 | AirbyteChartRelease = "airbyte-abctl" 7 | AirbyteIngress = "ingress-abctl" 8 | AirbyteNamespace = "airbyte-abctl" 9 | AirbyteKubeContext = "kind-airbyte-abctl" 10 | AirbyteRepoName = "airbyte" 11 | AirbyteRepoURLv1 = "https://airbytehq.github.io/helm-charts" 12 | AirbyteRepoURLv2 = "https://airbytehq.github.io/charts" 13 | NginxChartName = "nginx/ingress-nginx" 14 | NginxChartRelease = "ingress-nginx" 15 | NginxNamespace = "ingress-nginx" 16 | NginxRepoName = "nginx" 17 | NginxRepoURL = "https://kubernetes.github.io/ingress-nginx" 18 | 19 | // DockerAuthSecretName is the name of the secret which holds the docker authentication information. 20 | DockerAuthSecretName = "docker-auth" 21 | ) 22 | -------------------------------------------------------------------------------- /internal/validate/url_test.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIsURL(t *testing.T) { 10 | tests := []struct { 11 | input string 12 | want bool 13 | }{ 14 | {input: "https://example.com", want: true}, 15 | {input: "http://localhost:8080", want: true}, 16 | {input: "ftp://ftp.example.com"}, // not http(s) 17 | {input: "file:///tmp/file.txt"}, // not http(s) 18 | {input: "//example.com"}, // missing scheme 19 | {input: "example.com"}, // missing scheme 20 | {input: "/some/path"}, // path only 21 | {input: ""}, // empty string 22 | {input: "not a url"}, // invalid 23 | {input: "https:/example.com"}, // malformed scheme 24 | } 25 | 26 | for _, tt := range tests { 27 | t.Run(tt.input, func(t *testing.T) { 28 | got := IsURL(tt.input) 29 | assert.Equal(t, tt.want, got, "IsURL(%q)", tt.input) 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/airbox/credentials.go: -------------------------------------------------------------------------------- 1 | package airbox 2 | 3 | import "github.com/airbytehq/abctl/internal/auth" 4 | 5 | // CredentialStoreAdapter adapts ConfigStore to auth.CredentialsStore 6 | type CredentialStoreAdapter struct { 7 | cfg ConfigStore 8 | } 9 | 10 | // NewCredentialStoreAdapter creates a new credentials store adapter 11 | func NewCredentialStoreAdapter(cfg ConfigStore) *CredentialStoreAdapter { 12 | return &CredentialStoreAdapter{cfg: cfg} 13 | } 14 | 15 | // Load implements auth.CredentialsStore 16 | func (a *CredentialStoreAdapter) Load() (*auth.Credentials, error) { 17 | config, err := a.cfg.Load() 18 | if err != nil { 19 | return nil, err 20 | } 21 | return config.GetCredentials() 22 | } 23 | 24 | // Save implements auth.CredentialsStore 25 | func (a *CredentialStoreAdapter) Save(creds *auth.Credentials) error { 26 | config, err := a.cfg.Load() 27 | if err != nil { 28 | return err 29 | } 30 | 31 | config.SetCredentials(creds) 32 | 33 | return a.cfg.Save(config) 34 | } 35 | -------------------------------------------------------------------------------- /internal/docker/secret.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | ) 7 | 8 | // Secret generates a docker registry secret that can be stored as a k8s secret 9 | // and used to pull images from docker hub (or any other registry) in an 10 | // authenticated manner. 11 | // The format if the []byte in string form will be 12 | // 13 | // { 14 | // "auths": { 15 | // "[SERVER]": { 16 | // "username": "[USER]", 17 | // "password": "[PASS]", 18 | // "email": "[EMAIL]", 19 | // "auth"; "[base64 encoding of 'user:pass']" 20 | // } 21 | // } 22 | // } 23 | func Secret(server, user, pass, email string) ([]byte, error) { 24 | // map of the server to the credentials 25 | return json.Marshal(map[string]any{ 26 | "auths": map[string]any{ 27 | server: map[string]any{ 28 | "username": user, 29 | "password": pass, 30 | "email": email, 31 | "auth": base64.StdEncoding.EncodeToString([]byte(user + ":" + pass)), 32 | }, 33 | }, 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /internal/k8s/volumes.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // errInvalidVolumeMountSpec returns an error for an invalid volume mount spec. 9 | func errInvalidVolumeMountSpec(spec string) error { 10 | return fmt.Errorf("volume %s is not a valid volume spec, must be :", spec) 11 | } 12 | 13 | // ParseVolumeMounts parses a slice of volume mount specs in the format : 14 | // and returns a slice of ExtraVolumeMount. Returns an error if any spec is invalid. 15 | func ParseVolumeMounts(specs []string) ([]ExtraVolumeMount, error) { 16 | if len(specs) == 0 { 17 | return nil, nil 18 | } 19 | 20 | mounts := make([]ExtraVolumeMount, len(specs)) 21 | 22 | for i, spec := range specs { 23 | parts := strings.Split(spec, ":") 24 | if len(parts) != 2 { 25 | return nil, errInvalidVolumeMountSpec(spec) 26 | } 27 | mounts[i] = ExtraVolumeMount{ 28 | HostPath: parts[0], 29 | ContainerPath: parts[1], 30 | } 31 | } 32 | 33 | return mounts, nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/airbyte/log_scanner_test.go: -------------------------------------------------------------------------------- 1 | package airbyte 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | var testLogs = strings.TrimSpace(` 9 | nonjsonline 10 | {"timestamp":1734723317023,"message":"Waiting for database to become available...","level":"WARN","logSource":"platform","caller":{"className":"io.airbyte.db.check.DatabaseAvailabilityCheck","methodName":"check","lineNumber":38,"threadName":"main"},"throwable":null} 11 | `) 12 | 13 | func TestJavaLogScanner(t *testing.T) { 14 | s := NewLogScanner(strings.NewReader(testLogs)) 15 | 16 | expectLogLine := func(level, msg string) { 17 | s.Scan() 18 | 19 | if s.Line.Level != level { 20 | t.Errorf("expected level %q but got %q", level, s.Line.Level) 21 | } 22 | if s.Line.Message != msg { 23 | t.Errorf("expected msg %q but got %q", msg, s.Line.Message) 24 | } 25 | if s.Err() != nil { 26 | t.Errorf("unexpected error %v", s.Err()) 27 | } 28 | } 29 | 30 | expectLogLine("", "nonjsonline") 31 | expectLogLine("WARN", "Waiting for database to become available...") 32 | } 33 | -------------------------------------------------------------------------------- /internal/helm/meta.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "net/http" 5 | 6 | goHelm "github.com/mittwald/go-helm-client" 7 | "helm.sh/helm/v3/pkg/action" 8 | "helm.sh/helm/v3/pkg/chart" 9 | "helm.sh/helm/v3/pkg/chart/loader" 10 | ) 11 | 12 | // GetMetadataForRef returns the chart metadata for a local path or chart reference using the provided Helm client. 13 | func GetMetadataForRef(client goHelm.Client, chartRef string) (*chart.Metadata, error) { 14 | chart, _, err := client.GetChart(chartRef, &action.ChartPathOptions{}) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return chart.Metadata, nil 20 | } 21 | 22 | // GetMetadataForURL fetches a remote chart archive (.tgz) from the given URL and returns its chart metadata. 23 | func GetMetadataForURL(url string) (*chart.Metadata, error) { 24 | resp, err := http.Get(url) 25 | if err != nil { 26 | return nil, err 27 | } 28 | defer resp.Body.Close() 29 | 30 | chart, err := loader.LoadArchive(resp.Body) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return chart.Metadata, nil 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: "PR Title" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | pull-requests: read 12 | 13 | jobs: 14 | main: 15 | name: Validate PR title 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: amannn/action-semantic-pull-request@v5 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | with: 22 | types: | 23 | feat 24 | fix 25 | docs 26 | test 27 | chore 28 | build 29 | ci 30 | refactor 31 | revert 32 | design 33 | style 34 | perf 35 | subjectPattern: ^(?![A-Z]).+$ 36 | subjectPatternError: | 37 | The subject "{subject}" found in the pull request title "{title}" 38 | didn't match the configured pattern. Please ensure that the subject 39 | doesn't start with an uppercase character. 40 | 41 | -------------------------------------------------------------------------------- /internal/cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/airbytehq/abctl/internal/cmd/images" 7 | "github.com/airbytehq/abctl/internal/cmd/local" 8 | "github.com/airbytehq/abctl/internal/cmd/version" 9 | "github.com/airbytehq/abctl/internal/k8s" 10 | "github.com/airbytehq/abctl/internal/service" 11 | "github.com/alecthomas/kong" 12 | "github.com/pterm/pterm" 13 | ) 14 | 15 | type verbose bool 16 | 17 | func (v verbose) BeforeApply() error { 18 | pterm.EnableDebugMessages() 19 | return nil 20 | } 21 | 22 | type Cmd struct { 23 | Local local.Cmd `cmd:"" help:"Manage the local Airbyte installation."` 24 | Images images.Cmd `cmd:"" help:"Manage images used by Airbyte and abctl."` 25 | Version version.Cmd `cmd:"" help:"Display version information."` 26 | Verbose verbose `short:"v" help:"Enable verbose output."` 27 | } 28 | 29 | func (c *Cmd) BeforeApply(_ context.Context, kCtx *kong.Context) error { 30 | kCtx.BindTo(k8s.DefaultProvider, (*k8s.Provider)(nil)) 31 | kCtx.BindTo(service.DefaultManagerClientFactory, (*service.ManagerClientFactory)(nil)) 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Airbyte, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/k8s/namespace.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "k8s.io/client-go/tools/clientcmd" 8 | ) 9 | 10 | // GetCurrentNamespace returns the namespace from the current kubeconfig context. 11 | // If no namespace is set in the context, it returns "default". 12 | func GetCurrentNamespace() (string, error) { 13 | loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() 14 | config := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 15 | loadingRules, 16 | &clientcmd.ConfigOverrides{}, 17 | ) 18 | 19 | namespace, _, err := config.Namespace() 20 | if err != nil { 21 | return "", err 22 | } 23 | 24 | if namespace == "" { 25 | return "default", nil 26 | } 27 | 28 | return namespace, nil 29 | } 30 | 31 | // IsAbctlInitialized checks if abctl is initialized in the given namespace. 32 | // Returns an error with helpful message if not initialized. 33 | func IsAbctlInitialized(ctx context.Context, client Client, namespace string) error { 34 | _, err := client.ConfigMapGet(ctx, namespace, "abctl") 35 | if err != nil { 36 | return fmt.Errorf("abctl not initialized: %w (hint: run 'abctl init' first)", err) 37 | } 38 | return nil 39 | } -------------------------------------------------------------------------------- /internal/telemetry/mock.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import "context" 4 | 5 | var _ Client = (*MockClient)(nil) 6 | 7 | type MockClient struct { 8 | attrs map[string]string 9 | start func(context.Context, EventType) error 10 | success func(context.Context, EventType) error 11 | failure func(context.Context, EventType, error) error 12 | wrap func(context.Context, EventType, func() error) error 13 | } 14 | 15 | func (m *MockClient) Start(ctx context.Context, eventType EventType) error { 16 | return m.start(ctx, eventType) 17 | } 18 | 19 | func (m *MockClient) Success(ctx context.Context, eventType EventType) error { 20 | return m.success(ctx, eventType) 21 | } 22 | 23 | func (m *MockClient) Failure(ctx context.Context, eventType EventType, err error) error { 24 | return m.failure(ctx, eventType, err) 25 | } 26 | 27 | func (m *MockClient) Attr(key, val string) { 28 | if m.attrs == nil { 29 | m.attrs = map[string]string{} 30 | } 31 | m.attrs[key] = val 32 | } 33 | 34 | func (m *MockClient) User() string { 35 | return "test-user" 36 | } 37 | 38 | func (m *MockClient) Wrap(ctx context.Context, et EventType, f func() error) error { 39 | return m.wrap(ctx, et, f) 40 | } 41 | -------------------------------------------------------------------------------- /internal/cmd/delete/dataplane.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/airbytehq/abctl/internal/airbox" 8 | "github.com/airbytehq/abctl/internal/http" 9 | "github.com/airbytehq/abctl/internal/ui" 10 | ) 11 | 12 | // DataplaneCmd handles dataplane deletion. 13 | type DataplaneCmd struct { 14 | ID string `arg:"" required:"" help:"ID of the dataplane to delete."` 15 | Force bool `short:"f" help:"Force deletion without confirmation."` 16 | } 17 | 18 | // Run executes the delete dataplane command. 19 | func (c *DataplaneCmd) Run(ctx context.Context, cfg airbox.ConfigStore, httpClient http.HTTPDoer, apiFactory airbox.APIServiceFactory, ui ui.Provider) error { 20 | ui.Title("Deleting dataplane") 21 | 22 | apiClient, err := apiFactory(ctx, httpClient, cfg) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | // Delete via API using the ID directly 28 | err = apiClient.DeleteDataplane(ctx, c.ID) 29 | if err != nil { 30 | return fmt.Errorf("failed to delete dataplane: %w", err) 31 | } 32 | 33 | // Show success 34 | ui.ShowSuccess(fmt.Sprintf("Dataplane ID '%s' deleted successfully", c.ID)) 35 | ui.NewLine() 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/service/status.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/airbytehq/abctl/internal/common" 8 | "github.com/pterm/pterm" 9 | "go.opencensus.io/trace" 10 | ) 11 | 12 | // Status handles the status of local Airbyte. 13 | func (m *Manager) Status(ctx context.Context) error { 14 | _, span := trace.StartSpan(ctx, "command.Status") 15 | defer span.End() 16 | 17 | charts := []string{common.AirbyteChartRelease, common.NginxChartRelease} 18 | for _, name := range charts { 19 | m.spinner.UpdateText(fmt.Sprintf("Verifying %s Helm Chart installation status", name)) 20 | 21 | rel, err := m.helm.GetRelease(name) 22 | if err != nil { 23 | pterm.Warning.Println("Unable to fetch airbyte release") 24 | pterm.Debug.Printfln("unable to fetch airbyte release: %s", err) 25 | continue 26 | } 27 | 28 | pterm.Info.Println(fmt.Sprintf( 29 | "Found helm chart '%s'\n Status: %s\n Chart Version: %s\n App Version: %s", 30 | name, rel.Info.Status.String(), rel.Chart.Metadata.Version, rel.Chart.Metadata.AppVersion, 31 | )) 32 | } 33 | 34 | pterm.Info.Println(fmt.Sprintf("Airbyte should be accessible via http://localhost:%d", m.portHTTP)) 35 | 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /internal/api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/airbytehq/abctl/internal/http" 7 | ) 8 | 9 | // Service interface for Control Plane API operations 10 | type Service interface { 11 | // Organizations 12 | GetOrganization(ctx context.Context, organizationID string) (*Organization, error) 13 | ListOrganizations(ctx context.Context) ([]*Organization, error) 14 | 15 | // Regions 16 | CreateRegion(ctx context.Context, request CreateRegionRequest) (*Region, error) 17 | GetRegion(ctx context.Context, regionID string) (*Region, error) 18 | ListRegions(ctx context.Context, organizationID string) ([]*Region, error) 19 | 20 | // Dataplanes 21 | GetDataplane(ctx context.Context, id string) (*Dataplane, error) 22 | ListDataplanes(ctx context.Context) ([]Dataplane, error) 23 | CreateDataplane(ctx context.Context, req CreateDataplaneRequest) (*CreateDataplaneResponse, error) 24 | DeleteDataplane(ctx context.Context, id string) error 25 | } 26 | 27 | // Client handles Control Plane API operations 28 | type Client struct { 29 | http http.HTTPDoer 30 | } 31 | 32 | // NewClient creates a new API client 33 | func NewClient(httpDoer http.HTTPDoer) *Client { 34 | return &Client{ 35 | http: httpDoer, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/k8s/log_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/pterm/pterm" 10 | ) 11 | 12 | func TestLogger_HandleWarningHeader(t *testing.T) { 13 | b := bytes.NewBufferString("") 14 | pterm.Debug.Writer = b 15 | pterm.EnableDebugMessages() 16 | // remove color codes from output 17 | pterm.DisableColor() 18 | t.Cleanup(func() { 19 | pterm.Debug.Writer = os.Stdout 20 | pterm.DisableDebugMessages() 21 | pterm.EnableColor() 22 | }) 23 | 24 | tests := []struct { 25 | name string 26 | code int 27 | msg string 28 | want string 29 | }{ 30 | { 31 | name: "non 299 code", 32 | code: 300, 33 | msg: "test msg", 34 | }, 35 | { 36 | name: "empty msg", 37 | code: 299, 38 | }, 39 | { 40 | name: "happy path", 41 | code: 299, 42 | msg: "test msg", 43 | want: " DEBUG k8s - WARN: test msg\n", 44 | }, 45 | } 46 | 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | logger := Logger{} 50 | logger.HandleWarningHeader(tt.code, "", tt.msg) 51 | 52 | if d := cmp.Diff(tt.want, b.String()); d != "" { 53 | t.Error("unexpected output (-want, +got) =", d) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/cmd/output.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/airbytehq/abctl/internal/ui" 9 | ) 10 | 11 | // outputHandler defines how to handle a specific output format 12 | type outputHandler func(ui.Provider, any) error 13 | 14 | // outputHandlers maps format names to their handlers 15 | var outputHandlers = map[string]outputHandler{ 16 | "json": func(ui ui.Provider, data any) error { 17 | return ui.ShowJSON(data) 18 | }, 19 | "yaml": func(ui ui.Provider, data any) error { 20 | return ui.ShowYAML(data) 21 | }, 22 | } 23 | 24 | // RenderOutput displays data in the specified format using the UI provider 25 | // Defaults to JSON if format is empty 26 | func RenderOutput(uiProvider ui.Provider, data any, format string) error { 27 | // Default to JSON 28 | if format == "" { 29 | format = "json" 30 | } 31 | 32 | handler, exists := outputHandlers[format] 33 | if !exists { 34 | supported := make([]string, 0, len(outputHandlers)) 35 | for k := range outputHandlers { 36 | supported = append(supported, k) 37 | } 38 | // Sort supported since slice key order is not deterministic. 39 | slices.Sort(supported) 40 | return fmt.Errorf("unsupported output format: %s (supported: %s)", format, strings.Join(supported, ", ")) 41 | } 42 | 43 | return handler(uiProvider, data) 44 | } 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOPATH := $(shell go env GOPATH) 2 | ABCTL_VERSION?=dev 3 | 4 | .PHONY: build 5 | build: 6 | CGO_ENABLED=0 go build -trimpath -o build/ -ldflags "-w -X github.com/airbytehq/abctl/internal/build.Version=$(ABCTL_VERSION)" . 7 | 8 | .PHONY: clean 9 | clean: 10 | rm -rf build/ 11 | 12 | .PHONY: fmt 13 | fmt: 14 | go fmt ./... 15 | 16 | .PHONY: test 17 | test: 18 | go test ./... 19 | 20 | .PHONY: vet 21 | vet: 22 | go vet ./... 23 | 24 | .PHONY: mocks 25 | mocks: 26 | mockgen --source $(GOPATH)/pkg/mod/github.com/mittwald/go-helm-client@v0.12.15/interface.go -destination internal/helm/mock/mock.go -package mock 27 | mockgen --source internal/http/client.go -destination internal/http/mock/mock.go -package mock 28 | mockgen --source internal/ui/ui.go -destination internal/ui/mock/mock.go -package mock 29 | mockgen --source internal/airbox/config_store.go -destination internal/airbox/mock/config.go -package mock 30 | mockgen --source internal/auth/auth.go -destination internal/auth/mocks_creds_test.go -package auth 31 | mockgen --source internal/api/client.go -destination internal/api/mock/mock.go -package mock 32 | mockgen --source internal/k8s/cluster.go -destination internal/k8s/mock/cluster.go -package mock 33 | 34 | .PHONY: tools 35 | tools: 36 | go install go.uber.org/mock/mockgen@$(shell go list -m -f '{{.Version}}' go.uber.org/mock) 37 | -------------------------------------------------------------------------------- /internal/cmd/auth/logout.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/airbytehq/abctl/internal/airbox" 10 | "github.com/airbytehq/abctl/internal/ui" 11 | ) 12 | 13 | // LogoutCmd handles logout and credential cleanup 14 | type LogoutCmd struct { 15 | Namespace string `short:"n" help:"Target namespace (default: current kubeconfig context)."` 16 | } 17 | 18 | // Run executes the logout command 19 | func (c *LogoutCmd) Run(ctx context.Context, cfgStore airbox.ConfigStore, ui ui.Provider) error { 20 | ui.Title("Logging out of Airbyte") 21 | 22 | // Load airbox config 23 | cfg, err := cfgStore.Load() 24 | if err != nil { 25 | if errors.Is(err, os.ErrNotExist) { 26 | return airbox.NewConfigInitError("no airbox configuration found") 27 | } 28 | return fmt.Errorf("failed to load airbox config: %w", err) 29 | } 30 | 31 | // Check if user credentials exist 32 | if !cfg.IsAuthenticated() { 33 | return airbox.NewLoginError("not authenticated") 34 | } 35 | 36 | // Clear only the tokens, keep user identity intact 37 | cfg.Credentials = nil 38 | 39 | // Save updated config 40 | if err := cfgStore.Save(cfg); err != nil { 41 | return fmt.Errorf("failed to save config: %w", err) 42 | } 43 | 44 | ui.ShowSuccess("Successfully logged out!") 45 | ui.NewLine() 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/airbox/env.go: -------------------------------------------------------------------------------- 1 | package airbox 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/airbytehq/abctl/internal/auth" 7 | "github.com/airbytehq/abctl/internal/http" 8 | "github.com/kelseyhightower/envconfig" 9 | ) 10 | 11 | // OAuthEnvConfig holds environment-based configuration 12 | type OAuthEnvConfig struct { 13 | ClientID string `envconfig:"AIRBYTE_CLIENT_ID" required:"true"` 14 | ClientSecret string `envconfig:"AIRBYTE_CLIENT_SECRET" required:"true"` 15 | } 16 | 17 | // LoadOAuthEnvConfig loads configuration from environment variables 18 | func LoadOAuthEnvConfig() (*OAuthEnvConfig, error) { 19 | var cfg OAuthEnvConfig 20 | err := envconfig.Process("", &cfg) 21 | if err != nil { 22 | return nil, err 23 | } 24 | return &cfg, nil 25 | } 26 | 27 | // ToAuthClient creates an authenticated HTTP client from stored credentials 28 | func (c *OAuthEnvConfig) ToAuthClient(ctx context.Context, httpClient http.HTTPDoer, cfg ConfigStore) (auth.Provider, error) { 29 | // Create credentials store from config provider 30 | store := NewCredentialStoreAdapter(cfg) 31 | 32 | // Create OAuth2 provider that implements the Provider interface (HTTPDoer + CredentialsStore) 33 | // The provider IS the authenticated HTTP client and fetches initial tokens during creation 34 | provider, err := auth.NewOAuth2Provider(ctx, c.ClientID, c.ClientSecret, httpClient, store) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | return provider, nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/http/client.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | ) 8 | 9 | // Client handles HTTP requests with base URL resolution 10 | type Client struct { 11 | doer HTTPDoer 12 | baseURL string 13 | } 14 | 15 | // HTTPDoer interface for making HTTP requests 16 | type HTTPDoer interface { 17 | Do(req *http.Request) (*http.Response, error) 18 | } 19 | 20 | // NewClient creates an HTTP client with any HTTPDoer implementation 21 | func NewClient(baseURL string, doer HTTPDoer) (*Client, error) { 22 | return &Client{ 23 | doer: doer, 24 | baseURL: baseURL, 25 | }, nil 26 | } 27 | 28 | // Do performs an HTTP request, prepending base URL 29 | func (c *Client) Do(req *http.Request) (*http.Response, error) { 30 | if c.doer == nil { 31 | return nil, fmt.Errorf("nil pointer dereference: doer is nil") 32 | } 33 | 34 | fullURLStr, err := url.JoinPath(c.baseURL, req.URL.Path) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to join API base URL with request path: %w", err) 37 | } 38 | 39 | fullURL, err := url.Parse(fullURLStr) 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to parse URL: %w", err) 42 | } 43 | 44 | // Preserve the original query string 45 | fullURL.RawQuery = req.URL.RawQuery 46 | 47 | newReq := &http.Request{ 48 | Method: req.Method, 49 | URL: fullURL, 50 | Header: req.Header, 51 | Body: req.Body, 52 | } 53 | 54 | // Preserve the context. 55 | if req.Context() != nil { 56 | newReq = newReq.WithContext(req.Context()) 57 | } 58 | 59 | return c.doer.Do(newReq) 60 | } 61 | -------------------------------------------------------------------------------- /internal/cmd/get/dataplane.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | 8 | "github.com/airbytehq/abctl/internal/airbox" 9 | "github.com/airbytehq/abctl/internal/cmd" 10 | "github.com/airbytehq/abctl/internal/http" 11 | "github.com/airbytehq/abctl/internal/ui" 12 | ) 13 | 14 | // DataplaneCmd handles getting dataplane details. 15 | type DataplaneCmd struct { 16 | ID string `arg:"" optional:"" help:"ID of the dataplane (optional, lists all if not specified)."` 17 | Output string `short:"o" enum:"json,yaml" default:"json" help:"Output format (json, yaml)."` 18 | } 19 | 20 | // Run executes the get dataplane command. 21 | func (c *DataplaneCmd) Run(ctx context.Context, cfg airbox.ConfigStore, httpClient http.HTTPDoer, apiFactory airbox.APIServiceFactory, uiProvider ui.Provider) error { 22 | apiClient, err := apiFactory(ctx, httpClient, cfg) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | if c.ID != "" { 28 | // Get dataplane by ID 29 | dataplane, err := apiClient.GetDataplane(ctx, c.ID) 30 | if err != nil { 31 | return err 32 | } 33 | return cmd.RenderOutput(uiProvider, dataplane, c.Output) 34 | } 35 | 36 | // List all dataplanes 37 | dataplanes, err := apiClient.ListDataplanes(ctx) 38 | if err != nil { 39 | return fmt.Errorf("failed to list dataplanes: %w", err) 40 | } 41 | 42 | // Sort dataplanes by name for consistent output 43 | sort.Slice(dataplanes, func(i, j int) bool { 44 | return dataplanes[i].Name < dataplanes[j].Name 45 | }) 46 | 47 | return cmd.RenderOutput(uiProvider, dataplanes, c.Output) 48 | } 49 | -------------------------------------------------------------------------------- /internal/k8s/volumes_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestParseVolumeMounts(t *testing.T) { 10 | t.Parallel() 11 | 12 | tests := []struct { 13 | name string 14 | input []string 15 | expectMounts []ExtraVolumeMount 16 | expectErr error 17 | }{ 18 | { 19 | name: "empty input", 20 | }, 21 | { 22 | name: "single valid mount", 23 | input: []string{"/host:/container"}, 24 | expectMounts: []ExtraVolumeMount{{HostPath: "/host", ContainerPath: "/container"}}, 25 | }, 26 | { 27 | name: "multiple valid mounts", 28 | input: []string{"/a:/b", "/c:/d"}, 29 | expectMounts: []ExtraVolumeMount{{HostPath: "/a", ContainerPath: "/b"}, {HostPath: "/c", ContainerPath: "/d"}}, 30 | }, 31 | { 32 | name: "invalid spec (missing colon)", 33 | input: []string{"/hostcontainer"}, 34 | expectErr: errInvalidVolumeMountSpec("/hostcontainer"), 35 | }, 36 | { 37 | name: "invalid spec (too many colons)", 38 | input: []string{"/a:/b:/c"}, 39 | expectErr: errInvalidVolumeMountSpec("/a:/b:/c"), 40 | }, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | t.Parallel() 46 | mounts, err := ParseVolumeMounts(tt.input) 47 | assert.Equal(t, tt.expectMounts, mounts, "mounts should match") 48 | if tt.expectErr != nil { 49 | assert.EqualError(t, err, tt.expectErr.Error(), "errors should match") 50 | } else { 51 | assert.NoError(t, err) 52 | } 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/merge/docker.go: -------------------------------------------------------------------------------- 1 | package merge 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | // DockerImages merges two lists of Docker images. Images in b override images in a. 10 | func DockerImages(a, b []string) []string { 11 | imageMap := make(map[string]string) 12 | 13 | // Add A to map 14 | for _, img := range a { 15 | repo, tag := parseDockerImage(img) 16 | imageMap[repo] = tag 17 | } 18 | 19 | // Override or add from B 20 | for _, img := range b { 21 | repo, tag := parseDockerImage(img) 22 | imageMap[repo] = tag 23 | } 24 | 25 | // Reconstruct list 26 | var result []string 27 | for repo, tag := range imageMap { 28 | result = append(result, fmt.Sprintf("%s:%s", repo, tag)) 29 | } 30 | 31 | // Sort for deterministic output 32 | sort.Strings(result) 33 | 34 | return result 35 | } 36 | 37 | // Parse image into repo and tag 38 | func parseDockerImage(image string) (string, string) { 39 | // Find the last slash to identify where the image name starts 40 | lastSlash := strings.LastIndex(image, "/") 41 | 42 | // Look for a colon after the last slash (or from the beginning if no slash) 43 | searchStart := 0 44 | if lastSlash != -1 { 45 | searchStart = lastSlash + 1 46 | } 47 | 48 | // Find the first colon after the last slash (this separates image name from tag) 49 | colonIndex := strings.Index(image[searchStart:], ":") 50 | if colonIndex == -1 { 51 | return image, "latest" 52 | } 53 | 54 | // Adjust the colon index to be relative to the full string 55 | colonIndex += searchStart 56 | return image[:colonIndex], image[colonIndex+1:] 57 | } 58 | -------------------------------------------------------------------------------- /internal/http/mock/mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/http/client.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen --source internal/http/client.go -destination internal/http/mock/mock.go -package mock 7 | // 8 | 9 | // Package mock is a generated GoMock package. 10 | package mock 11 | 12 | import ( 13 | http "net/http" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockHTTPDoer is a mock of HTTPDoer interface. 20 | type MockHTTPDoer struct { 21 | ctrl *gomock.Controller 22 | recorder *MockHTTPDoerMockRecorder 23 | isgomock struct{} 24 | } 25 | 26 | // MockHTTPDoerMockRecorder is the mock recorder for MockHTTPDoer. 27 | type MockHTTPDoerMockRecorder struct { 28 | mock *MockHTTPDoer 29 | } 30 | 31 | // NewMockHTTPDoer creates a new mock instance. 32 | func NewMockHTTPDoer(ctrl *gomock.Controller) *MockHTTPDoer { 33 | mock := &MockHTTPDoer{ctrl: ctrl} 34 | mock.recorder = &MockHTTPDoerMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockHTTPDoer) EXPECT() *MockHTTPDoerMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // Do mocks base method. 44 | func (m *MockHTTPDoer) Do(req *http.Request) (*http.Response, error) { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "Do", req) 47 | ret0, _ := ret[0].(*http.Response) 48 | ret1, _ := ret[1].(error) 49 | return ret0, ret1 50 | } 51 | 52 | // Do indicates an expected call of Do. 53 | func (mr *MockHTTPDoerMockRecorder) Do(req any) *gomock.Call { 54 | mr.mock.ctrl.T.Helper() 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Do", reflect.TypeOf((*MockHTTPDoer)(nil).Do), req) 56 | } 57 | -------------------------------------------------------------------------------- /internal/paths/paths.go: -------------------------------------------------------------------------------- 1 | package paths 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | const ( 9 | FileKubeconfig = "abctl.kubeconfig" 10 | 11 | // PvMinio is the persistent volume directory for Minio storage. 12 | PvMinio = "airbyte-minio-pv" 13 | // PvLocal is the persistent volume directory for Local storage. 14 | PvLocal = "airbyte-local-pv" 15 | // PvPsql is the persistent volume directory for Psql storage. 16 | PvPsql = "airbyte-volume-db" 17 | ) 18 | 19 | var ( 20 | // UserHome is the user's home directory 21 | UserHome = func() string { 22 | h, _ := os.UserHomeDir() 23 | return h 24 | }() 25 | 26 | // Airbyte is the full path to the ~/.airbyte directory 27 | Airbyte = airbyte() 28 | 29 | // AbCtl is the full path to the ~/.airbyte/abctl directory 30 | AbCtl = abctl() 31 | 32 | // Data is the full path to the ~/.airbyte/abctl/data directory 33 | Data = data() 34 | 35 | // Kubeconfig is the full path to the kubeconfig file 36 | Kubeconfig = kubeconfig() 37 | 38 | // HelmRepoConfig is the full path to where helm stores 39 | // its repository configurations. 40 | HelmRepoConfig = helmRepoConfig() 41 | 42 | // HelmRepoCache is the full path to where helm stores 43 | // its cached data. 44 | HelmRepoCache = helmRepoCache() 45 | ) 46 | 47 | func airbyte() string { 48 | return filepath.Join(UserHome, ".airbyte") 49 | } 50 | 51 | func abctl() string { 52 | return filepath.Join(airbyte(), "abctl") 53 | } 54 | 55 | func data() string { 56 | return filepath.Join(abctl(), "data") 57 | } 58 | 59 | func kubeconfig() string { 60 | return filepath.Join(abctl(), FileKubeconfig) 61 | } 62 | 63 | func helmRepoConfig() string { return filepath.Join(abctl(), ".helmrepo") } 64 | 65 | func helmRepoCache() string { return filepath.Join(abctl(), ".helmcache") } 66 | -------------------------------------------------------------------------------- /internal/ui/spinner.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/spinner" 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | // Operation result messages for spinner 11 | type ( 12 | operationSuccessMsg struct{} 13 | operationErrorMsg struct{ err error } 14 | ) 15 | 16 | // SpinnerModel implements a bubbletea model for showing progress 17 | type SpinnerModel struct { 18 | spinner spinner.Model 19 | message string 20 | done bool 21 | operationError error 22 | } 23 | 24 | func newSpinnerModel(message string) SpinnerModel { 25 | s := spinner.New() 26 | s.Spinner = spinner.Points 27 | return SpinnerModel{ 28 | spinner: s, 29 | message: message, 30 | } 31 | } 32 | 33 | // Init initializes the spinner model with tick animation 34 | func (m SpinnerModel) Init() tea.Cmd { 35 | return m.spinner.Tick 36 | } 37 | 38 | // Update handles input events for the spinner model 39 | func (m SpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 40 | switch msg := msg.(type) { 41 | case operationSuccessMsg: 42 | m.done = true 43 | return m, tea.Quit 44 | case operationErrorMsg: 45 | m.operationError = msg.err 46 | m.done = true 47 | return m, tea.Quit 48 | case tea.KeyMsg: 49 | switch msg.String() { 50 | case "ctrl+c": 51 | m.done = true 52 | return m, tea.Quit 53 | } 54 | default: 55 | var cmd tea.Cmd 56 | m.spinner, cmd = m.spinner.Update(msg) 57 | return m, cmd 58 | } 59 | return m, nil 60 | } 61 | 62 | // View renders the spinner model 63 | func (m SpinnerModel) View() string { 64 | if m.done { 65 | // Clear the line that was used for the spinner 66 | return "\033[2K\r" // Clear line and return to start 67 | } 68 | return fmt.Sprintf("%s %s", m.message, m.spinner.View()) 69 | } 70 | -------------------------------------------------------------------------------- /internal/ui/spinner_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/charmbracelet/bubbles/spinner" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestSpinnerModel(t *testing.T) { 13 | model := newSpinnerModel("Loading...") 14 | 15 | // Test Init 16 | cmd := model.Init() 17 | assert.NotNil(t, cmd) // Should start the spinner 18 | 19 | // Test View 20 | view := model.View() 21 | assert.Contains(t, view, "Loading...") 22 | 23 | // Test Update with success message 24 | result, cmd := model.Update(operationSuccessMsg{}) 25 | model = result.(SpinnerModel) 26 | assert.NotNil(t, cmd) // Should quit 27 | assert.True(t, model.done) 28 | 29 | // Test Update with tick 30 | model.done = false 31 | result, cmd = model.Update(spinner.TickMsg{}) 32 | model = result.(SpinnerModel) 33 | assert.NotNil(t, cmd) // Should continue ticking 34 | 35 | // Test Update with error message 36 | model.done = false 37 | result, cmd = model.Update(operationErrorMsg{err: fmt.Errorf("test error")}) 38 | model = result.(SpinnerModel) 39 | assert.NotNil(t, cmd) // Should quit 40 | assert.Equal(t, fmt.Errorf("test error"), model.operationError) 41 | assert.True(t, model.done) 42 | } 43 | 44 | func TestSpinnerModel_ViewDone(t *testing.T) { 45 | model := newSpinnerModel("Loading...") 46 | model.done = true 47 | 48 | view := model.View() 49 | assert.Equal(t, "\033[2K\r", view) // Should return clear line 50 | } 51 | 52 | func TestSpinnerModel_UpdateMoreCases(t *testing.T) { 53 | model := newSpinnerModel("Loading...") 54 | 55 | // Test with Ctrl+C 56 | result, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 57 | model = result.(SpinnerModel) 58 | assert.NotNil(t, cmd) // Should quit 59 | assert.True(t, model.done) 60 | } 61 | -------------------------------------------------------------------------------- /internal/telemetry/dnt_test.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestDNT(t *testing.T) { 11 | // It's possible the host running this test already has the envVarDNT flag set. 12 | // Capture its value before removing it so that it can be restored at the end of the test. 13 | origEnvVars := map[string]string{} 14 | if origEnvVar, ok := os.LookupEnv(envVarDNT); ok { 15 | origEnvVars[envVarDNT] = origEnvVar 16 | if err := os.Unsetenv(envVarDNT); err != nil { 17 | t.Fatal("unable to unset environment variable:", err) 18 | } 19 | } 20 | 21 | test := []struct { 22 | name string 23 | envVar *string 24 | expected bool 25 | }{ 26 | { 27 | name: "unset", 28 | expected: false, 29 | }, 30 | { 31 | name: "empty string", 32 | envVar: sPtr(""), 33 | expected: true, 34 | }, 35 | { 36 | name: "0 value", 37 | envVar: sPtr("0"), 38 | expected: true, 39 | }, 40 | { 41 | name: "any value", 42 | envVar: sPtr("any value goes here"), 43 | expected: true, 44 | }, 45 | } 46 | 47 | for _, tt := range test { 48 | t.Run(tt.name, func(t *testing.T) { 49 | if tt.envVar == nil { 50 | if err := os.Unsetenv(envVarDNT); err != nil { 51 | t.Fatal("unable to unset environment variable:", err) 52 | } 53 | } else { 54 | if err := os.Setenv(envVarDNT, *tt.envVar); err != nil { 55 | t.Fatal("unable to set environment variable:", err) 56 | } 57 | } 58 | 59 | if d := cmp.Diff(tt.expected, DNT()); d != "" { 60 | t.Errorf("DNT() mismatch (-want +got):\n%s", d) 61 | } 62 | }) 63 | } 64 | 65 | t.Cleanup(func() { 66 | for k, v := range origEnvVars { 67 | _ = os.Setenv(k, v) 68 | } 69 | }) 70 | } 71 | 72 | func sPtr(s string) *string { 73 | return &s 74 | } 75 | -------------------------------------------------------------------------------- /internal/telemetry/noop_test.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/google/go-cmp/cmp/cmpopts" 10 | ) 11 | 12 | func TestNoopClient(t *testing.T) { 13 | cli := NoopClient{} 14 | ctx := context.Background() 15 | if err := cli.Start(ctx, Install); err != nil { 16 | t.Error(err) 17 | } 18 | if err := cli.Success(ctx, Install); err != nil { 19 | t.Error(err) 20 | } 21 | if err := cli.Failure(ctx, Install, errors.New("")); err != nil { 22 | t.Error(err) 23 | } 24 | 25 | cli.Attr("k", "v'") 26 | 27 | if d := cmp.Diff("", cli.User()); d != "" { 28 | t.Errorf("user should be nil (-want +got): %s", d) 29 | } 30 | } 31 | 32 | // Verify that the func() error is actually called for the NoopClient.Wrap 33 | func TestNoopClient_Wrap(t *testing.T) { 34 | t.Run("fn is called without error", func(t *testing.T) { 35 | called := false 36 | fn := func() error { 37 | called = true 38 | return nil 39 | } 40 | 41 | cli := NoopClient{} 42 | 43 | if err := cli.Wrap(context.Background(), Install, fn); err != nil { 44 | t.Fatal("unexpected error", err) 45 | } 46 | 47 | if d := cmp.Diff(true, called); d != "" { 48 | t.Errorf("function should have been called (-want, +got): %s", d) 49 | } 50 | }) 51 | 52 | t.Run("fn is called with error", func(t *testing.T) { 53 | called := false 54 | expectedErr := errors.New("test") 55 | fn := func() error { 56 | called = true 57 | return expectedErr 58 | } 59 | 60 | cli := NoopClient{} 61 | 62 | err := cli.Wrap(context.Background(), Install, fn) 63 | if d := cmp.Diff(expectedErr, err, cmpopts.EquateErrors()); d != "" { 64 | t.Errorf("function should have returned an error (-want, +got): %s", d) 65 | } 66 | 67 | if d := cmp.Diff(true, called); d != "" { 68 | t.Errorf("function should have been called (-want, +got): %s", d) 69 | } 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /internal/paths/paths_test.go: -------------------------------------------------------------------------------- 1 | package paths 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func Test_Paths(t *testing.T) { 12 | t.Run("FileKubeconfig", func(t *testing.T) { 13 | if d := cmp.Diff("abctl.kubeconfig", FileKubeconfig); d != "" { 14 | t.Errorf("FileKubeconfig mismatch (-want +got):\n%s", d) 15 | } 16 | }) 17 | 18 | t.Run("UserHome", func(t *testing.T) { 19 | exp, _ := os.UserHomeDir() 20 | if d := cmp.Diff(exp, UserHome); d != "" { 21 | t.Errorf("UserHome mismatch (-want +got):\n%s", d) 22 | } 23 | }) 24 | 25 | t.Run("Airbyte", func(t *testing.T) { 26 | exp := filepath.Join(UserHome, ".airbyte") 27 | if d := cmp.Diff(exp, Airbyte); d != "" { 28 | t.Errorf("Airbyte mismatch (-want +got):\n%s", d) 29 | } 30 | }) 31 | 32 | t.Run("AbCtl", func(t *testing.T) { 33 | exp := filepath.Join(UserHome, ".airbyte", "abctl") 34 | if d := cmp.Diff(exp, AbCtl); d != "" { 35 | t.Errorf("AbCtl mismatch (-want +got):\n%s", d) 36 | } 37 | }) 38 | 39 | t.Run("Data", func(t *testing.T) { 40 | exp := filepath.Join(UserHome, ".airbyte", "abctl", "data") 41 | if d := cmp.Diff(exp, Data); d != "" { 42 | t.Errorf("Data mismatch (-want +got):\n%s", d) 43 | } 44 | }) 45 | 46 | t.Run("Kubeconfig", func(t *testing.T) { 47 | exp := filepath.Join(UserHome, ".airbyte", "abctl", "abctl.kubeconfig") 48 | if d := cmp.Diff(exp, Kubeconfig); d != "" { 49 | t.Errorf("Kubeconfig mismatch (-want +got):\n%s", d) 50 | } 51 | }) 52 | 53 | t.Run("HelmRepoConfig", func(t *testing.T) { 54 | exp := filepath.Join(UserHome, ".airbyte", "abctl", ".helmrepo") 55 | if d := cmp.Diff(exp, HelmRepoConfig); d != "" { 56 | t.Errorf("HelmRepoConfig mismatch (-want +got):\n%s", d) 57 | } 58 | }) 59 | 60 | t.Run("HelmRepoCache", func(t *testing.T) { 61 | exp := filepath.Join(UserHome, ".airbyte", "abctl", ".helmcache") 62 | if d := cmp.Diff(exp, HelmRepoCache); d != "" { 63 | t.Errorf("HelmRepoCache mismatch (-want +got):\n%s", d) 64 | } 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /internal/cmd/local/deployments.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/airbytehq/abctl/internal/k8s" 8 | "github.com/airbytehq/abctl/internal/service" 9 | "github.com/airbytehq/abctl/internal/telemetry" 10 | "github.com/pterm/pterm" 11 | "go.opencensus.io/trace" 12 | ) 13 | 14 | type DeploymentsCmd struct { 15 | Restart string `help:"Deployment to restart."` 16 | } 17 | 18 | func (d *DeploymentsCmd) Run(ctx context.Context, telClient telemetry.Client, provider k8s.Provider) error { 19 | ctx, span := trace.StartSpan(ctx, "local deployments") 20 | defer span.End() 21 | 22 | k8sClient, err := service.DefaultK8s(provider.Kubeconfig, provider.Context) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | spinner := &pterm.DefaultSpinner 28 | if err := checkDocker(ctx, telClient, spinner); err != nil { 29 | return err 30 | } 31 | 32 | return telClient.Wrap(ctx, telemetry.Deployments, func() error { 33 | return d.deployments(ctx, k8sClient, spinner) 34 | }) 35 | } 36 | 37 | func (d *DeploymentsCmd) deployments(ctx context.Context, k8sClient k8s.Client, spinner *pterm.SpinnerPrinter) error { 38 | if d.Restart == "" { 39 | spinner.UpdateText("Fetching deployments") 40 | deployments, err := k8sClient.DeploymentList(ctx, airbyteNamespace) 41 | if err != nil { 42 | pterm.Error.Println("Unable to list deployments") 43 | return fmt.Errorf("unable to list deployments: %w", err) 44 | } 45 | 46 | if len(deployments.Items) == 0 { 47 | pterm.Info.Println("No deployments found") 48 | return nil 49 | } 50 | output := "Found the following deployments:" 51 | for _, deployment := range deployments.Items { 52 | output += "\n " + deployment.Name 53 | } 54 | pterm.Info.Println(output) 55 | 56 | return nil 57 | } 58 | 59 | spinner.UpdateText(fmt.Sprintf("Restarting deployment %s", d.Restart)) 60 | if err := k8sClient.DeploymentRestart(ctx, airbyteNamespace, d.Restart); err != nil { 61 | pterm.Error.Println(fmt.Sprintf("Unable to restart airbyte deployment %s", d.Restart)) 62 | return fmt.Errorf("unable to restart airbyte deployment: %w", err) 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | run-name: Create Release ${{ inputs.tag_name }} by @${{ github.actor }} 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | tag_name: 8 | description: 'Tag name' 9 | required: true 10 | type: string 11 | 12 | permissions: 13 | contents: write 14 | 15 | 16 | jobs: 17 | create-release: 18 | name: Build and publish release 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Validate tag 22 | run: | 23 | if [[ ! "${{ inputs.tag_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 24 | echo "tag is invalid: must be in the form 'v0.0.0'" 25 | exit 1 26 | fi 27 | - name: Create tag 28 | uses: actions/github-script@v5 29 | with: 30 | script: | 31 | github.rest.git.createRef({ 32 | owner: context.repo.owner, 33 | repo: context.repo.repo, 34 | ref: 'refs/tags/${{ inputs.tag_name }}', 35 | sha: context.sha 36 | }) 37 | - uses: actions/checkout@v4 38 | with: 39 | fetch-depth: 0 40 | - uses: actions/setup-go@v5 41 | with: 42 | go-version: 'stable' 43 | 44 | - name: Run GoReleaser 45 | uses: goreleaser/goreleaser-action@v6 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.github_token }} 48 | with: 49 | distribution: goreleaser 50 | version: "~> v2" 51 | args: release --clean 52 | 53 | - uses: actions/checkout@v4 54 | with: 55 | repository: airbytehq/homebrew-tap 56 | ssh-key: ${{ secrets.ABCTL_HOMEBREW_KEY }} 57 | - name: Replace version 58 | run: | 59 | sed -i 's/ABCTL_VERSION = ".*"/ABCTL_VERSION = "${{ inputs.tag_name }}"/' Formula/abctl.rb 60 | - name: Show diff 61 | run: git diff 62 | - name: Commit changes 63 | run: | 64 | git config user.email ${{ secrets.PRODENG_BOT_EMAIL }} 65 | git config user.name "airbyte-prodeng" 66 | git commit -a -m "chore: update abctl to ${{ inputs.tag_name }}" 67 | - name: Push change 68 | run: git push 69 | -------------------------------------------------------------------------------- /internal/helm/helm.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/airbytehq/abctl/internal/abctl" 8 | "github.com/airbytehq/abctl/internal/paths" 9 | goHelm "github.com/mittwald/go-helm-client" 10 | "github.com/pterm/pterm" 11 | "k8s.io/client-go/tools/clientcmd" 12 | ) 13 | 14 | func ClientOptions(namespace string) *goHelm.Options { 15 | logger := helmLogger{} 16 | return &goHelm.Options{ 17 | Namespace: namespace, 18 | Output: logger, 19 | DebugLog: logger.Debug, 20 | Debug: true, 21 | RepositoryCache: paths.HelmRepoCache, 22 | RepositoryConfig: paths.HelmRepoConfig, 23 | } 24 | } 25 | 26 | // New returns the default helm client 27 | func New(kubecfg, kubectx, namespace string) (goHelm.Client, error) { 28 | // Use default loading rules if kubecfg is empty (same logic as service.DefaultK8s) 29 | var loadingRules *clientcmd.ClientConfigLoadingRules 30 | if kubecfg == "" { 31 | // This will use KUBECONFIG env var or default ~/.kube/config 32 | loadingRules = clientcmd.NewDefaultClientConfigLoadingRules() 33 | } else { 34 | loadingRules = &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubecfg} 35 | } 36 | 37 | k8sCfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 38 | loadingRules, 39 | &clientcmd.ConfigOverrides{CurrentContext: kubectx}, 40 | ) 41 | 42 | restCfg, err := k8sCfg.ClientConfig() 43 | if err != nil { 44 | return nil, fmt.Errorf("%w: unable to create rest config: %w", abctl.ErrKubernetes, err) 45 | } 46 | 47 | helm, err := goHelm.NewClientFromRestConf(&goHelm.RestConfClientOptions{ 48 | Options: ClientOptions(namespace), 49 | RestConfig: restCfg, 50 | }) 51 | if err != nil { 52 | return nil, fmt.Errorf("unable to create helm client: %w", err) 53 | } 54 | 55 | return helm, nil 56 | } 57 | 58 | var _ io.Writer = (*helmLogger)(nil) 59 | 60 | // helmLogger is used by the Client to convert all helm output into debug logs. 61 | type helmLogger struct{} 62 | 63 | func (d helmLogger) Write(p []byte) (int, error) { 64 | pterm.Debug.Println(fmt.Sprintf("helm: %s", string(p))) 65 | return len(p), nil 66 | } 67 | 68 | func (d helmLogger) Debug(format string, v ...interface{}) { 69 | pterm.Debug.Println(fmt.Sprintf("helm: "+format, v...)) 70 | } 71 | -------------------------------------------------------------------------------- /internal/airbox/config_validations.go: -------------------------------------------------------------------------------- 1 | package airbox 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // ValidateEndpointURL performs basic validation on an endpoint URL 9 | func ValidateEndpointURL(endpoint string) error { 10 | if endpoint == "" { 11 | return fmt.Errorf("endpoint URL is required") 12 | } 13 | if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { 14 | return fmt.Errorf("endpoint must start with http:// or https://") 15 | } 16 | return nil 17 | } 18 | 19 | // ValidateCompanyIdentifier validates a company identifier for cloud deployment. 20 | // This validation matches Airbyte platform SSO requirements: only checks for non-empty 21 | // after trimming whitespace. The platform accepts any characters including spaces and 22 | // special characters, so we defer complex validation to the server. 23 | func ValidateCompanyIdentifier(companyID string) error { 24 | if strings.TrimSpace(companyID) == "" { 25 | return fmt.Errorf("company identifier is required") 26 | } 27 | return nil 28 | } 29 | 30 | // ValidateDataplaneName validates a dataplane name follows Kubernetes DNS-1123 subdomain rules. 31 | // These constraints are required because dataplane names become Kubernetes resource names: 32 | // - Max 63 characters (DNS label limit) 33 | // - Start with lowercase letter (K8s requirement) 34 | // - Only lowercase letters, numbers, hyphens (DNS-safe characters) 35 | // - Cannot end with hyphen (DNS requirement) 36 | func ValidateDataplaneName(name string) error { 37 | if name == "" { 38 | return fmt.Errorf("name cannot be empty") 39 | } 40 | 41 | if len(name) > 63 { 42 | return fmt.Errorf("name cannot exceed 63 characters") 43 | } 44 | 45 | // Must start with a letter 46 | if name[0] < 'a' || name[0] > 'z' { 47 | return fmt.Errorf("name must start with a lowercase letter") 48 | } 49 | 50 | // Only lowercase alphanumeric and hyphens allowed 51 | for i, char := range name { 52 | if (char < 'a' || char > 'z') && 53 | (char < '0' || char > '9') && 54 | char != '-' { 55 | return fmt.Errorf("name can only contain lowercase letters, numbers, and hyphens (invalid character at position %d)", i+1) 56 | } 57 | } 58 | 59 | // Cannot end with hyphen 60 | if name[len(name)-1] == '-' { 61 | return fmt.Errorf("name cannot end with a hyphen") 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/cmd/images/manifest_cmd.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | goHelm "github.com/mittwald/go-helm-client" 8 | 9 | "github.com/airbytehq/abctl/internal/common" 10 | "github.com/airbytehq/abctl/internal/helm" 11 | "github.com/airbytehq/abctl/internal/paths" 12 | "github.com/airbytehq/abctl/internal/service" 13 | "github.com/airbytehq/abctl/internal/trace" 14 | ) 15 | 16 | type ManifestCmd struct { 17 | Chart string `help:"Path to chart." xor:"chartver"` 18 | ChartVersion string `help:"Version of the chart." xor:"chartver"` 19 | Values string `type:"existingfile" help:"An Airbyte helm chart values file to configure helm."` 20 | } 21 | 22 | func (c *ManifestCmd) Run(ctx context.Context, newSvcMgrClients service.ManagerClientFactory) error { 23 | ctx, span := trace.NewSpan(ctx, "images manifest") 24 | defer span.End() 25 | 26 | // Load the required service manager clients. We only need the Helm client 27 | // for image manifest operations. 28 | _, helmClient, err := newSvcMgrClients(paths.Kubeconfig, common.AirbyteKubeContext) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | images, err := c.findAirbyteImages(ctx, helmClient) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | for _, img := range images { 39 | fmt.Println(img) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func (c *ManifestCmd) findAirbyteImages(ctx context.Context, helmClient goHelm.Client) ([]string, error) { 46 | valuesYaml, err := helm.BuildAirbyteValues(ctx, helm.ValuesOpts{ 47 | ValuesFile: c.Values, 48 | }, c.ChartVersion) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | // Determine and set defaults for chart flags. 54 | err = c.setDefaultChartFlags(helmClient) 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to set chart flag defaults: %w", err) 57 | } 58 | 59 | return helm.FindImagesFromChart(helmClient, valuesYaml, c.Chart, c.ChartVersion) 60 | } 61 | 62 | func (c *ManifestCmd) setDefaultChartFlags(helmClient goHelm.Client) error { 63 | resolver := helm.NewChartResolver(helmClient) 64 | resolvedChart, resolvedVersion, err := resolver.ResolveChartReference(c.Chart, c.ChartVersion) 65 | if err != nil { 66 | return fmt.Errorf("failed to resolve chart flags: %w", err) 67 | } 68 | 69 | c.Chart = resolvedChart 70 | c.ChartVersion = resolvedVersion 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /internal/update/update.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/airbytehq/abctl/internal/build" 12 | "golang.org/x/mod/semver" 13 | ) 14 | 15 | var ErrDevVersion = errors.New("dev version not supported") 16 | 17 | type doer interface { 18 | Do(req *http.Request) (*http.Response, error) 19 | } 20 | 21 | // Check looks to see if there is a newer version of abctl. 22 | // This is accomplished by fetching the latest github tag and comparing it to the version provided. 23 | // Returns the latest version, or an empty string if we're already running the latest version. 24 | // Will return ErrDevVersion if the build.Version is currently set to "dev". 25 | func Check(ctx context.Context) (string, error) { 26 | ctx, updateCancel := context.WithTimeout(ctx, 2*time.Second) 27 | defer updateCancel() 28 | return check(ctx, http.DefaultClient, build.Version) 29 | } 30 | 31 | func check(ctx context.Context, doer doer, version string) (string, error) { 32 | if version == "dev" { 33 | return "", ErrDevVersion 34 | } 35 | 36 | latest, err := latest(ctx, doer) 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | if semver.Compare(version, latest) < 0 { 42 | return latest, nil 43 | } 44 | 45 | // if we're here then our version is the latest 46 | return "", nil 47 | } 48 | 49 | const url = "https://api.github.com/repos/airbytehq/abctl/releases/latest" 50 | 51 | func latest(ctx context.Context, doer doer) (string, error) { 52 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 53 | if err != nil { 54 | return "", fmt.Errorf("unable to create request: %w", err) 55 | } 56 | 57 | res, err := doer.Do(req) 58 | if err != nil { 59 | return "", fmt.Errorf("unable to do request: %w", err) 60 | } 61 | defer res.Body.Close() 62 | 63 | if res.StatusCode != http.StatusOK { 64 | return "", fmt.Errorf("unable to do request, status code: %d", res.StatusCode) 65 | } 66 | 67 | var latest struct { 68 | TagName string `json:"tag_name"` 69 | } 70 | 71 | decoder := json.NewDecoder(res.Body) 72 | if err := decoder.Decode(&latest); err != nil { 73 | return "", fmt.Errorf("unable to decode response: %w", err) 74 | } 75 | 76 | if !semver.IsValid(latest.TagName) { 77 | return "", fmt.Errorf("invalid semver tag: %s", latest.TagName) 78 | } 79 | 80 | return latest.TagName, nil 81 | } 82 | -------------------------------------------------------------------------------- /internal/airbox/config_store.go: -------------------------------------------------------------------------------- 1 | package airbox 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | const ( 12 | // EnvConfigPath is the environment variable that sets the path to the Airbox configuration. 13 | EnvConfigPath = "AIRBOXCONFIG" 14 | ) 15 | 16 | // ConfigStore interface for performing config operations on the store. 17 | type ConfigStore interface { 18 | Load() (*Config, error) 19 | Save(config *Config) error 20 | GetPath() string 21 | Exists() bool 22 | } 23 | 24 | // FileConfigStore implements ConfigStore using filesystem. 25 | type FileConfigStore struct{} 26 | 27 | // DefaultConfigPath returns the default path for airbox config. 28 | func DefaultConfigPath() string { 29 | home, err := os.UserHomeDir() 30 | if err != nil { 31 | return filepath.Join(".", ".airbyte", "airbox", "config") 32 | } 33 | return filepath.Join(home, ".airbyte", "airbox", "config") 34 | } 35 | 36 | // Load and return the configuration from the file store. 37 | func (p *FileConfigStore) Load() (*Config, error) { 38 | data, err := os.ReadFile(p.GetPath()) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to read config file: %w", err) 41 | } 42 | 43 | var config Config 44 | if err := yaml.Unmarshal(data, &config); err != nil { 45 | return nil, fmt.Errorf("failed to parse config file: %w", err) 46 | } 47 | 48 | return &config, nil 49 | } 50 | 51 | // Save writes the configuration to the file store. 52 | func (p *FileConfigStore) Save(config *Config) error { 53 | configPath := p.GetPath() 54 | 55 | // Ensure directory exists 56 | if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { 57 | return fmt.Errorf("failed to create config directory: %w", err) 58 | } 59 | 60 | data, err := yaml.Marshal(config) 61 | if err != nil { 62 | return fmt.Errorf("failed to marshal config: %w", err) 63 | } 64 | 65 | if err := os.WriteFile(configPath, data, 0o600); err != nil { 66 | return fmt.Errorf("failed to write config file: %w", err) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | // GetPath returns configuration file path. 73 | func (p *FileConfigStore) GetPath() string { 74 | if path := os.Getenv(EnvConfigPath); path != "" { 75 | return path 76 | } 77 | return DefaultConfigPath() 78 | } 79 | 80 | // Exists checks if the config file exists 81 | func (p *FileConfigStore) Exists() bool { 82 | _, err := os.Stat(p.GetPath()) 83 | return err == nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/cmd/local/local.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | 9 | "github.com/pterm/pterm" 10 | 11 | "github.com/airbytehq/abctl/internal/abctl" 12 | "github.com/airbytehq/abctl/internal/k8s" 13 | "github.com/airbytehq/abctl/internal/paths" 14 | ) 15 | 16 | type Cmd struct { 17 | Credentials CredentialsCmd `cmd:"" help:"Get local Airbyte user credentials."` 18 | Install InstallCmd `cmd:"" help:"Install local Airbyte."` 19 | Deployments DeploymentsCmd `cmd:"" help:"View local Airbyte deployments."` 20 | Status StatusCmd `cmd:"" help:"Get local Airbyte status."` 21 | Uninstall UninstallCmd `cmd:"" help:"Uninstall local Airbyte."` 22 | } 23 | 24 | func (c *Cmd) BeforeApply() error { 25 | if _, envVarDNT := os.LookupEnv("DO_NOT_TRACK"); envVarDNT { 26 | pterm.Info.Println("Telemetry collection disabled (DO_NOT_TRACK)") 27 | } 28 | 29 | if err := checkAirbyteDir(); err != nil { 30 | return fmt.Errorf("%w: %w", abctl.ErrAirbyteDir, err) 31 | } 32 | 33 | return nil 34 | } 35 | 36 | func (c *Cmd) AfterApply(provider k8s.Provider) error { 37 | pterm.Info.Println(fmt.Sprintf( 38 | "Using Kubernetes provider:\n Provider: %s\n Kubeconfig: %s\n Context: %s", 39 | provider.Name, provider.Kubeconfig, provider.Context, 40 | )) 41 | 42 | return nil 43 | } 44 | 45 | // checkAirbyteDir verifies that, if the paths.Airbyte directory exists, that it has proper permissions. 46 | // If the directory does not have the proper permissions, this method will attempt to fix them. 47 | // A nil response either indicates that either: 48 | // - no paths.Airbyte directory exists 49 | // - the permissions are already correct 50 | // - this function was able to fix the incorrect permissions. 51 | func checkAirbyteDir() error { 52 | fileInfo, err := os.Stat(paths.Airbyte) 53 | if err != nil { 54 | if errors.Is(err, fs.ErrNotExist) { 55 | // nothing to do, directory will be created later on 56 | return nil 57 | } 58 | return fmt.Errorf("unable to determine status of '%s': %w", paths.Airbyte, err) 59 | } 60 | 61 | if !fileInfo.IsDir() { 62 | return errors.New(paths.Airbyte + " is not a directory") 63 | } 64 | 65 | if fileInfo.Mode().Perm() >= 0o744 { 66 | // directory has minimal permissions 67 | return nil 68 | } 69 | 70 | if err := os.Chmod(paths.Airbyte, 0o744); err != nil { 71 | return fmt.Errorf("unable to change permissions of '%s': %w", paths.Airbyte, err) 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /internal/build/build.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "fmt" 5 | "runtime/debug" 6 | "strings" 7 | 8 | "golang.org/x/mod/semver" 9 | ) 10 | 11 | // Version is the build of this tool. 12 | // The expectation is that this will be set during build time via ldflags or via the BuildInfo if "go install"ed. 13 | // Supported values: "dev", any semver (with or without a 'v', if no 'v' exists, one will be added). 14 | // Any invalid version will be replaced with "invalid (BAD_VERSION)". 15 | var Version = "dev" 16 | 17 | // Revision is the git hash which built this tool. 18 | // This value is automatically set if the buildInfoFunc function returns the "vcs.revision" setting. 19 | var Revision string 20 | 21 | // Modified is true if there are local code modifications when this binary was built. 22 | // This value is automatically set if the buildInfoFunc function returns the "vcs.modified" setting. 23 | var Modified bool 24 | 25 | // ModificationTime is the time, in RFC3339 format, of this binary. 26 | // This value is automatically set fi the buildInfoFunc function returns the "vcs.time" settings. 27 | var ModificationTime string 28 | 29 | // buildInfoFunc matches the debug.ReadBuildInfo method, redefined here for testing purposes. 30 | type buildInfoFunc func() (*debug.BuildInfo, bool) 31 | 32 | // readBuildInfo is a function pointer to debug.ReadBuildInfo, defined here for testing purposes. 33 | var readBuildInfo buildInfoFunc = debug.ReadBuildInfo 34 | 35 | // setVersion sets the Version variable correctly. 36 | // This method is only separated out from the init method for testing purposes and should only 37 | // be called by init and the unit tests. 38 | func setVersion() { 39 | if Version == "dev" { 40 | buildInfo, ok := readBuildInfo() 41 | if ok { 42 | if buildInfo.Main.Version != "" && buildInfo.Main.Version != "(devel)" { 43 | Version = buildInfo.Main.Version 44 | } 45 | for _, kv := range buildInfo.Settings { 46 | switch kv.Key { 47 | case "vcs.modified": 48 | Modified = kv.Value == "true" 49 | case "vcs.time": 50 | ModificationTime = kv.Value 51 | case "vcs.revision": 52 | Revision = kv.Value 53 | } 54 | } 55 | } 56 | } 57 | 58 | if Version != "dev" { 59 | origVersion := Version 60 | if !strings.HasPrefix(Version, "v") { 61 | Version = "v" + Version 62 | } 63 | if !semver.IsValid(Version) { 64 | Version = fmt.Sprintf("invalid (%s)", origVersion) 65 | } 66 | } 67 | } 68 | 69 | func init() { 70 | setVersion() 71 | } 72 | -------------------------------------------------------------------------------- /internal/cmd/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "github.com/airbytehq/abctl/internal/build" 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/pterm/pterm" 11 | ) 12 | 13 | func TestCmd_Output(t *testing.T) { 14 | b := bytes.NewBufferString("") 15 | pterm.SetDefaultOutput(b) 16 | t.Cleanup(func() { 17 | pterm.SetDefaultOutput(os.Stdout) 18 | }) 19 | 20 | tests := []struct { 21 | name string 22 | version string 23 | revision string 24 | modificationTime string 25 | modified bool 26 | expected string 27 | }{ 28 | { 29 | name: "version defined", 30 | version: "v0.0.0", 31 | expected: "version: v0.0.0\n", 32 | }, 33 | { 34 | name: "revision defined", 35 | version: "v0.0.0", 36 | revision: "d34db33f", 37 | expected: "version: v0.0.0\nrevision: d34db33f\n", 38 | }, 39 | { 40 | name: "modification time defined", 41 | version: "v0.0.0", 42 | modificationTime: "time-goes-here", 43 | expected: "version: v0.0.0\ntime: time-goes-here\n", 44 | }, 45 | { 46 | name: "modified defined", 47 | version: "v0.0.0", 48 | modified: true, 49 | expected: "version: v0.0.0\nmodified: true\n", 50 | }, 51 | { 52 | name: "everything defined", 53 | version: "v0.0.0", 54 | revision: "d34db33f", 55 | modificationTime: "time-goes-here", 56 | modified: true, 57 | expected: "version: v0.0.0\nrevision: d34db33f\ntime: time-goes-here\nmodified: true\n", 58 | }, 59 | } 60 | 61 | origVersion := build.Version 62 | origRevision := build.Revision 63 | origModification := build.ModificationTime 64 | origModified := build.Modified 65 | 66 | for _, tt := range tests { 67 | t.Run(tt.name, func(t *testing.T) { 68 | 69 | t.Cleanup(func() { 70 | build.Version = origVersion 71 | build.Revision = origRevision 72 | build.ModificationTime = origModification 73 | build.Modified = origModified 74 | b.Reset() 75 | }) 76 | 77 | build.Version = tt.version 78 | build.Revision = tt.revision 79 | build.ModificationTime = tt.modificationTime 80 | build.Modified = tt.modified 81 | 82 | cmd := Cmd{} 83 | 84 | if err := cmd.Run(); err != nil { 85 | t.Fatal(err) 86 | } 87 | 88 | if d := cmp.Diff(tt.expected, b.String()); d != "" { 89 | t.Error("cmd mismatch (-want +got):\n", d) 90 | } 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /internal/api/organizations.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | const ( 12 | organizationsPath = "/v1/organizations" 13 | ) 14 | 15 | // Organization represents an Airbyte organization 16 | type Organization struct { 17 | ID string `json:"organizationId"` 18 | Name string `json:"organizationName"` 19 | Email string `json:"email"` 20 | } 21 | 22 | // GetOrganization retrieves a specific organization by ID 23 | func (c *Client) GetOrganization(ctx context.Context, organizationID string) (*Organization, error) { 24 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, organizationsPath+"/"+organizationID, nil) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to create request: %w", err) 27 | } 28 | 29 | resp, err := c.http.Do(req) 30 | if err != nil { 31 | return nil, fmt.Errorf("request failed: %w", err) 32 | } 33 | defer func() { _ = resp.Body.Close() }() // Connection cleanup, error doesn't affect functionality 34 | 35 | if resp.StatusCode != http.StatusOK { 36 | body, _ := io.ReadAll(resp.Body) 37 | return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) 38 | } 39 | 40 | var organization Organization 41 | if err := json.NewDecoder(resp.Body).Decode(&organization); err != nil { 42 | return nil, fmt.Errorf("failed to decode response: %w", err) 43 | } 44 | 45 | return &organization, nil 46 | } 47 | 48 | // ListOrganizations retrieves organizations for the authenticated user 49 | func (c *Client) ListOrganizations(ctx context.Context) ([]*Organization, error) { 50 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, organizationsPath, nil) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to create request: %w", err) 53 | } 54 | 55 | resp, err := c.http.Do(req) 56 | if err != nil { 57 | return nil, fmt.Errorf("request failed: %w", err) 58 | } 59 | defer func() { _ = resp.Body.Close() }() // Connection cleanup, error doesn't affect functionality 60 | 61 | if resp.StatusCode != http.StatusOK { 62 | body, _ := io.ReadAll(resp.Body) 63 | return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) 64 | } 65 | 66 | // Decode response according to OpenAPI spec: OrganizationsResponse with data field 67 | var response struct { 68 | Data []*Organization `json:"data"` 69 | } 70 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 71 | return nil, fmt.Errorf("failed to decode response: %w", err) 72 | } 73 | 74 | return response.Data, nil 75 | } 76 | -------------------------------------------------------------------------------- /internal/cmd/auth/logout_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "go.uber.org/mock/gomock" 10 | 11 | "github.com/airbytehq/abctl/internal/airbox" 12 | airboxmock "github.com/airbytehq/abctl/internal/airbox/mock" 13 | "github.com/airbytehq/abctl/internal/auth" 14 | uimock "github.com/airbytehq/abctl/internal/ui/mock" 15 | ) 16 | 17 | func TestLogoutCmd_Run(t *testing.T) { 18 | tests := []struct { 19 | name string 20 | expectedError string 21 | setupMocks func(ctrl *gomock.Controller) (airbox.ConfigStore, *uimock.MockProvider) 22 | }{ 23 | { 24 | name: "success logout", 25 | setupMocks: func(ctrl *gomock.Controller) (airbox.ConfigStore, *uimock.MockProvider) { 26 | mockStore := airboxmock.NewMockConfigStore(ctrl) 27 | mockUI := uimock.NewMockProvider(ctrl) 28 | 29 | gomock.InOrder( 30 | mockUI.EXPECT().Title("Logging out of Airbyte"), 31 | mockStore.EXPECT().Load().Return(&airbox.Config{ 32 | Credentials: &auth.Credentials{ 33 | AccessToken: "test-token", 34 | TokenType: "Bearer", 35 | ExpiresAt: time.Now().Add(time.Hour), 36 | }, 37 | }, nil), 38 | mockStore.EXPECT().Save(gomock.Any()).Return(nil), 39 | mockUI.EXPECT().ShowSuccess("Successfully logged out!"), 40 | mockUI.EXPECT().NewLine(), 41 | ) 42 | 43 | return mockStore, mockUI 44 | }, 45 | }, 46 | { 47 | name: "config save error", 48 | expectedError: "failed to save config", 49 | setupMocks: func(ctrl *gomock.Controller) (airbox.ConfigStore, *uimock.MockProvider) { 50 | mockStore := airboxmock.NewMockConfigStore(ctrl) 51 | mockUI := uimock.NewMockProvider(ctrl) 52 | 53 | gomock.InOrder( 54 | mockUI.EXPECT().Title("Logging out of Airbyte"), 55 | mockStore.EXPECT().Load().Return(&airbox.Config{ 56 | Credentials: &auth.Credentials{ 57 | AccessToken: "test-token", 58 | }, 59 | }, nil), 60 | mockStore.EXPECT().Save(gomock.Any()).Return(assert.AnError), 61 | ) 62 | 63 | return mockStore, mockUI 64 | }, 65 | }, 66 | } 67 | 68 | for _, tt := range tests { 69 | t.Run(tt.name, func(t *testing.T) { 70 | ctrl := gomock.NewController(t) 71 | defer ctrl.Finish() 72 | 73 | mockStore, mockUI := tt.setupMocks(ctrl) 74 | 75 | cmd := &LogoutCmd{} 76 | err := cmd.Run(context.Background(), mockStore, mockUI) 77 | 78 | if tt.expectedError != "" { 79 | assert.Error(t, err) 80 | assert.Contains(t, err.Error(), tt.expectedError) 81 | } else { 82 | assert.NoError(t, err) 83 | } 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /internal/ui/select.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | // SelectModel implements a bubbletea model for selecting from a list 10 | type SelectModel struct { 11 | prompt string 12 | options []string 13 | selected int 14 | cancelled bool 15 | done bool 16 | viewport int // Top of viewport for scrolling 17 | } 18 | 19 | func newSelectModel(prompt string, options []string) SelectModel { 20 | return SelectModel{ 21 | prompt: prompt, 22 | options: options, 23 | } 24 | } 25 | 26 | // Init initializes the select model 27 | func (m SelectModel) Init() tea.Cmd { 28 | return nil 29 | } 30 | 31 | // Update handles input events for the select model 32 | func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 33 | switch msg := msg.(type) { 34 | case tea.KeyMsg: 35 | switch msg.String() { 36 | case "up", "k": 37 | if m.selected > 0 { 38 | m.selected-- 39 | m.adjustViewport() 40 | } 41 | case "down", "j": 42 | if m.selected < len(m.options)-1 { 43 | m.selected++ 44 | m.adjustViewport() 45 | } 46 | case "enter": 47 | m.done = true 48 | return m, tea.Quit 49 | case "ctrl+c", "esc": 50 | m.cancelled = true 51 | return m, tea.Quit 52 | } 53 | } 54 | return m, nil 55 | } 56 | 57 | // adjustViewport ensures the selected item is visible in the viewport 58 | func (m *SelectModel) adjustViewport() { 59 | const maxVisible = 10 // Show max 10 items at once 60 | 61 | // If selected is above viewport, scroll up 62 | if m.selected < m.viewport { 63 | m.viewport = m.selected 64 | } 65 | 66 | // If selected is below viewport, scroll down 67 | if m.selected >= m.viewport+maxVisible { 68 | m.viewport = m.selected - maxVisible + 1 69 | } 70 | } 71 | 72 | // View renders the select model 73 | func (m SelectModel) View() string { 74 | s := fmt.Sprintf("\033[1m%s\033[0m\n\n", m.prompt) // Bold text with extra newline 75 | 76 | // If done, only show the selected option 77 | if m.done { 78 | s += fmt.Sprintf("> %s\n\n", m.options[m.selected]) 79 | return s 80 | } 81 | 82 | const maxVisible = 10 83 | visibleEnd := m.viewport + maxVisible 84 | if visibleEnd > len(m.options) { 85 | visibleEnd = len(m.options) 86 | } 87 | 88 | // Show only the visible portion of options 89 | for i := m.viewport; i < visibleEnd; i++ { 90 | cursor := " " 91 | if i == m.selected { 92 | cursor = ">" 93 | } 94 | s += fmt.Sprintf("%s %s\n", cursor, m.options[i]) 95 | } 96 | 97 | s += "\nPress enter to select, esc to cancel" 98 | return s 99 | } 100 | -------------------------------------------------------------------------------- /.github/workflows/airbox-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Airbox Release 2 | run-name: Create Airbox Release ${{ inputs.tag_name }} by @${{ github.actor }} 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | tag_name: 8 | description: 'Tag name (e.g., airbox-v0.1.0)' 9 | required: true 10 | type: string 11 | 12 | permissions: 13 | contents: write 14 | 15 | jobs: 16 | release: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v6 23 | with: 24 | go-version: '1.24' 25 | 26 | - name: Get version from tag 27 | id: version 28 | run: | 29 | TAG_NAME="${{ inputs.tag_name }}" 30 | VERSION=${TAG_NAME#airbox-} 31 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT 32 | echo "FULL_TAG=$TAG_NAME" >> $GITHUB_OUTPUT 33 | if [[ "$VERSION" =~ (beta|rc|alpha) ]]; then 34 | echo "PRERELEASE=true" >> $GITHUB_OUTPUT 35 | else 36 | echo "PRERELEASE=false" >> $GITHUB_OUTPUT 37 | fi 38 | 39 | - name: Build binaries 40 | run: | 41 | GOOS=linux GOARCH=amd64 go build -o ./dist/airbox-linux-amd64 ./cmd/airbox 42 | GOOS=linux GOARCH=arm64 go build -o ./dist/airbox-linux-arm64 ./cmd/airbox 43 | GOOS=darwin GOARCH=amd64 go build -o ./dist/airbox-darwin-amd64 ./cmd/airbox 44 | GOOS=darwin GOARCH=arm64 go build -o ./dist/airbox-darwin-arm64 ./cmd/airbox 45 | GOOS=windows GOARCH=amd64 go build -o ./dist/airbox-windows-amd64.exe ./cmd/airbox 46 | 47 | cd ./dist 48 | tar -czf airbox-linux-amd64.tar.gz airbox-linux-amd64 49 | tar -czf airbox-linux-arm64.tar.gz airbox-linux-arm64 50 | tar -czf airbox-darwin-amd64.tar.gz airbox-darwin-amd64 51 | tar -czf airbox-darwin-arm64.tar.gz airbox-darwin-arm64 52 | zip airbox-windows-amd64.zip airbox-windows-amd64.exe 53 | 54 | - name: Create Tag 55 | run: | 56 | git config user.name github-actions 57 | git config user.email github-actions@github.com 58 | git tag ${{ inputs.tag_name }} 59 | git push origin ${{ inputs.tag_name }} 60 | 61 | - name: Create Release 62 | uses: softprops/action-gh-release@v1 63 | with: 64 | tag_name: ${{ inputs.tag_name }} 65 | name: Airbox ${{ steps.version.outputs.VERSION }} 66 | body: Airbox ${{ steps.version.outputs.VERSION }} 67 | files: | 68 | dist/*.tar.gz 69 | dist/*.zip 70 | draft: false 71 | prerelease: ${{ steps.version.outputs.PRERELEASE }} 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | -------------------------------------------------------------------------------- /internal/helm/meta_test.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/airbytehq/abctl/internal/helm/mock" 11 | "github.com/stretchr/testify/assert" 12 | "go.uber.org/mock/gomock" 13 | "helm.sh/helm/v3/pkg/chart" 14 | ) 15 | 16 | func TestGetMetadataForRef(t *testing.T) { 17 | t.Parallel() 18 | ctrl := gomock.NewController(t) 19 | defer ctrl.Finish() 20 | 21 | mockClient := mock.NewMockClient(ctrl) 22 | 23 | tests := []struct { 24 | name string 25 | chartRef string 26 | setupMock func() 27 | wantMeta *chart.Metadata 28 | wantErr bool 29 | }{ 30 | { 31 | name: "success", 32 | chartRef: "valid", 33 | setupMock: func() { 34 | mockClient.EXPECT(). 35 | GetChart("valid", gomock.Any()). 36 | Return(&chart.Chart{Metadata: &chart.Metadata{Version: "1.2.3"}}, "", nil) 37 | }, 38 | wantMeta: &chart.Metadata{Version: "1.2.3"}, 39 | }, 40 | { 41 | name: "error from client", 42 | chartRef: "bad", 43 | setupMock: func() { 44 | mockClient.EXPECT(). 45 | GetChart("bad", gomock.Any()). 46 | Return(nil, "", errors.New("fail")) 47 | }, 48 | wantErr: true, 49 | }, 50 | } 51 | 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | if tt.setupMock != nil { 55 | tt.setupMock() 56 | } 57 | meta, err := GetMetadataForRef(mockClient, tt.chartRef) 58 | if tt.wantErr { 59 | assert.Error(t, err) 60 | } else { 61 | assert.NoError(t, err) 62 | assert.Equal(t, tt.wantMeta, meta) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | func TestGetMetadataForURL(t *testing.T) { 69 | t.Parallel() 70 | 71 | // Serve a valid chart archive with Chart.yaml for testing URL-based chart resolution 72 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 73 | if strings.Contains(r.URL.Path, "notfound") { 74 | w.WriteHeader(http.StatusNotFound) 75 | return 76 | } 77 | w.Write([]byte("not a real tgz")) 78 | })) 79 | defer ts.Close() 80 | 81 | tests := []struct { 82 | name string 83 | url string 84 | wantErr bool 85 | }{ 86 | {"valid but invalid archive", ts.URL + "/chart.tgz", true}, 87 | {"404", ts.URL + "/notfound", true}, 88 | {"unreachable", "http://127.0.0.1:0/", true}, 89 | } 90 | 91 | for _, tt := range tests { 92 | t.Run(tt.name, func(t *testing.T) { 93 | _, err := GetMetadataForURL(tt.url) 94 | if tt.wantErr { 95 | assert.Error(t, err) 96 | } else { 97 | assert.NoError(t, err) 98 | } 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /internal/cmd/local/status.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/airbytehq/abctl/internal/k8s" 8 | "github.com/airbytehq/abctl/internal/service" 9 | "github.com/airbytehq/abctl/internal/telemetry" 10 | "github.com/airbytehq/abctl/internal/trace" 11 | "github.com/pterm/pterm" 12 | ) 13 | 14 | type StatusCmd struct{} 15 | 16 | func (s *StatusCmd) Run(ctx context.Context, provider k8s.Provider, telClient telemetry.Client) error { 17 | ctx, span := trace.NewSpan(ctx, "local status") 18 | defer span.End() 19 | 20 | spinner := &pterm.DefaultSpinner 21 | if err := checkDocker(ctx, telClient, spinner); err != nil { 22 | return err 23 | } 24 | 25 | return telClient.Wrap(ctx, telemetry.Status, func() error { 26 | return status(ctx, provider, telClient, spinner) 27 | }) 28 | } 29 | 30 | func checkDocker(ctx context.Context, telClient telemetry.Client, spinner *pterm.SpinnerPrinter) error { 31 | spinner, _ = spinner.Start("Starting status check") 32 | spinner.UpdateText("Checking for Docker installation") 33 | 34 | _, err := dockerInstalled(ctx, telClient) 35 | if err != nil { 36 | pterm.Error.Println("Unable to determine if Docker is installed") 37 | return fmt.Errorf("unable to determine docker installation status: %w", err) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func status(ctx context.Context, provider k8s.Provider, telClient telemetry.Client, spinner *pterm.SpinnerPrinter) error { 44 | spinner.UpdateText(fmt.Sprintf("Checking for existing Kubernetes cluster '%s'", provider.ClusterName)) 45 | 46 | cluster, err := provider.Cluster(ctx) 47 | if err != nil { 48 | pterm.Error.Printfln("Unable to determine status of any existing '%s' cluster", provider.ClusterName) 49 | return err 50 | } 51 | 52 | if !cluster.Exists(ctx) { 53 | pterm.Warning.Println("Airbyte does not appear to be installed locally") 54 | return nil 55 | } 56 | 57 | pterm.Success.Printfln("Existing cluster '%s' found", provider.ClusterName) 58 | spinner.UpdateText(fmt.Sprintf("Validating existing cluster '%s'", provider.ClusterName)) 59 | 60 | port, err := getPort(ctx, provider.ClusterName) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | svcMgr, err := service.NewManager(provider, 66 | service.WithPortHTTP(port), 67 | service.WithTelemetryClient(telClient), 68 | service.WithSpinner(spinner), 69 | ) 70 | if err != nil { 71 | pterm.Error.Printfln("Failed to initialize 'local' command") 72 | return fmt.Errorf("unable to initialize local command: %w", err) 73 | } 74 | 75 | if err := svcMgr.Status(ctx); err != nil { 76 | spinner.Fail("Unable to install Airbyte locally") 77 | return err 78 | } 79 | 80 | _ = spinner.Stop() 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/ui/textinput.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/textinput" 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | // TextInputModel implements a bubbletea model for text input 11 | type TextInputModel struct { 12 | prompt string 13 | value string 14 | placeholder string 15 | input textinput.Model 16 | validator func(string) error 17 | cancelled bool 18 | error string 19 | done bool 20 | } 21 | 22 | func newTextInputModel(prompt string, defaultValue string, validator func(string) error) TextInputModel { 23 | ti := textinput.New() 24 | ti.Focus() 25 | ti.Placeholder = defaultValue 26 | ti.CharLimit = 200 27 | ti.Width = 60 28 | 29 | return TextInputModel{ 30 | prompt: prompt, 31 | placeholder: defaultValue, 32 | input: ti, 33 | validator: validator, 34 | } 35 | } 36 | 37 | // Init initializes the text input model with cursor blink 38 | func (m TextInputModel) Init() tea.Cmd { 39 | return textinput.Blink 40 | } 41 | 42 | // Update handles input events for the text input model 43 | func (m TextInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 44 | // First let the textinput handle the message to support paste 45 | var cmd tea.Cmd 46 | m.input, cmd = m.input.Update(msg) 47 | 48 | // Keep our value in sync with the textinput 49 | m.value = m.input.Value() 50 | 51 | switch msg := msg.(type) { 52 | case tea.KeyMsg: 53 | switch msg.String() { 54 | case "enter": 55 | // Use placeholder if value is empty 56 | if m.value == "" && m.placeholder != "" { 57 | m.value = m.placeholder 58 | } 59 | 60 | if m.validator != nil { 61 | if err := m.validator(m.value); err != nil { 62 | m.error = err.Error() 63 | return m, nil 64 | } 65 | } 66 | m.done = true 67 | return m, tea.Quit 68 | case "ctrl+c", "esc": 69 | m.cancelled = true 70 | return m, tea.Quit 71 | default: 72 | m.error = "" // Clear error on input change 73 | } 74 | } 75 | 76 | return m, cmd 77 | } 78 | 79 | // View renders the text input model 80 | func (m TextInputModel) View() string { 81 | s := fmt.Sprintf("\033[1m%s\033[0m\n\n", m.prompt) 82 | 83 | // If done, only show the final value 84 | if m.done { 85 | s += fmt.Sprintf("> %s\n\n", m.value) 86 | return s 87 | } 88 | 89 | s += fmt.Sprintf("%s\n", m.input.View()) 90 | 91 | if m.error != "" { 92 | s += fmt.Sprintf("\n\033[31mError: %s\033[0m\n", m.error) 93 | s += "Press enter to retry or esc to cancel\n" 94 | } else { 95 | s += "\nPress enter to confirm, esc to cancel\n" 96 | } 97 | 98 | return s 99 | } 100 | -------------------------------------------------------------------------------- /internal/k8s/kind/config.go: -------------------------------------------------------------------------------- 1 | package kind 2 | 3 | import ( 4 | "github.com/airbytehq/abctl/internal/paths" 5 | ) 6 | 7 | // IngressPort is the default port that Airbyte will deploy to. 8 | const IngressPort = 8000 9 | 10 | type Config struct { 11 | Kind string `yaml:"kind"` 12 | ApiVersion string `yaml:"apiVersion"` 13 | Nodes []Node `yaml:"nodes"` 14 | } 15 | 16 | type Node struct { 17 | Role string `yaml:"role"` 18 | Image string `yaml:"image"` 19 | Labels map[string]string `yaml:"labels"` 20 | 21 | ExtraMounts []Mount `yaml:"extraMounts"` 22 | ExtraPortMappings []PortMapping `yaml:"extraPortMappings"` 23 | 24 | // KubeadmConfigPatches are applied to the generated kubeadm config as 25 | // strategic merge patches to `kustomize build` internally 26 | // https://github.com/kubernetes/community/blob/a9cf5c8f3380bb52ebe57b1e2dbdec136d8dd484/contributors/devel/sig-api-machinery/strategic-merge-patch.md 27 | // This should be an inline yaml blob-string 28 | KubeadmConfigPatches []string `yaml:"kubeadmConfigPatches"` 29 | } 30 | 31 | type Mount struct { 32 | ContainerPath string `yaml:"containerPath"` 33 | HostPath string `yaml:"hostPath"` 34 | ReadOnly bool `yaml:"readOnly"` 35 | SelinuxRelabel bool `yaml:"selinuxRelabel"` 36 | Propagation string `yaml:"propagation"` 37 | } 38 | 39 | type PortMapping struct { 40 | ContainerPort int32 `yaml:"containerPort"` 41 | HostPort int32 `yaml:"hostPort"` 42 | ListenAddress string `yaml:"listenAddress"` 43 | Protocol string `yaml:"protocol"` 44 | } 45 | 46 | func DefaultConfig() *Config { 47 | kubeadmConfigPatch := `kind: InitConfiguration 48 | nodeRegistration: 49 | kubeletExtraArgs: 50 | node-labels: "ingress-ready=true"` 51 | 52 | cfg := &Config{ 53 | Kind: "Cluster", 54 | ApiVersion: "kind.x-k8s.io/v1alpha4", 55 | Nodes: []Node{ 56 | { 57 | Role: "control-plane", 58 | KubeadmConfigPatches: []string{kubeadmConfigPatch}, 59 | ExtraMounts: []Mount{ 60 | { 61 | HostPath: paths.Data, 62 | ContainerPath: "/var/local-path-provisioner", 63 | }, 64 | }, 65 | ExtraPortMappings: []PortMapping{ 66 | { 67 | ContainerPort: 80, 68 | HostPort: IngressPort, 69 | }, 70 | }, 71 | }, 72 | }, 73 | } 74 | 75 | return cfg 76 | } 77 | 78 | func (c *Config) WithVolumeMount(hostPath string, containerPath string) *Config { 79 | c.Nodes[0].ExtraMounts = append(c.Nodes[0].ExtraMounts, Mount{HostPath: hostPath, ContainerPath: containerPath}) 80 | return c 81 | } 82 | 83 | func (c *Config) WithHostPort(port int) *Config { 84 | c.Nodes[0].ExtraPortMappings[0].HostPort = int32(port) 85 | return c 86 | } 87 | -------------------------------------------------------------------------------- /cmd/airbox/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/airbytehq/abctl/internal/airbox" 8 | airboxauth "github.com/airbytehq/abctl/internal/auth" 9 | "github.com/airbytehq/abctl/internal/cmd/auth" 10 | "github.com/airbytehq/abctl/internal/cmd/config" 11 | "github.com/airbytehq/abctl/internal/cmd/delete" 12 | "github.com/airbytehq/abctl/internal/cmd/get" 13 | "github.com/airbytehq/abctl/internal/cmd/install" 14 | "github.com/airbytehq/abctl/internal/helm" 15 | "github.com/airbytehq/abctl/internal/http" 16 | "github.com/airbytehq/abctl/internal/k8s" 17 | "github.com/airbytehq/abctl/internal/ui" 18 | "github.com/alecthomas/kong" 19 | ) 20 | 21 | // rootCmd represents the airbox command. 22 | type rootCmd struct { 23 | Config config.Cmd `cmd:"" help:"Initialize the configuration."` 24 | Auth auth.Cmd `cmd:"" help:"Authenticate with Airbyte."` 25 | Get get.Cmd `cmd:"" help:"Get Airbyte resources."` 26 | Delete delete.Cmd `cmd:"" help:"Delete Airbyte resources."` 27 | Install install.Cmd `cmd:"" help:"Install an Airbyte dataplane."` 28 | } 29 | 30 | // Global UI provider for terminal output 31 | var uiProvider ui.Provider 32 | 33 | func init() { 34 | uiProvider = ui.New() 35 | } 36 | 37 | func (c *rootCmd) BeforeApply(ctx context.Context, kCtx *kong.Context) error { 38 | kCtx.BindTo(&airbox.FileConfigStore{}, (*airbox.ConfigStore)(nil)) 39 | kCtx.BindTo(http.DefaultClient, (*http.HTTPDoer)(nil)) 40 | kCtx.BindTo(airbox.NewAPIService, (*airbox.APIServiceFactory)(nil)) 41 | kCtx.BindTo(helm.DefaultFactory, (*helm.Factory)(nil)) 42 | kCtx.BindTo(k8s.DefaultClusterFactory, (*k8s.ClusterFactory)(nil)) 43 | kCtx.BindTo(uiProvider, (*ui.Provider)(nil)) 44 | kCtx.BindTo(airboxauth.DefaultStateGenerator, (*airboxauth.StateGenerator)(nil)) 45 | return nil 46 | } 47 | 48 | func main() { 49 | ctx := context.Background() 50 | cmd := rootCmd{} 51 | 52 | parser, err := kong.New( 53 | &cmd, 54 | kong.Name("airbox"), 55 | kong.BindToProvider(bindCtx(ctx)), 56 | ) 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | parsed, err := parser.Parse(os.Args[1:]) 62 | if err != nil { 63 | parser.FatalIfErrorf(err) 64 | } 65 | 66 | err = parsed.BindToProvider(func() (context.Context, error) { 67 | return ctx, nil 68 | }) 69 | if err != nil { 70 | panic(err) 71 | } 72 | 73 | err = parsed.Run() 74 | if err != nil { 75 | uiProvider.NewLine() 76 | uiProvider.ShowError(err) 77 | uiProvider.NewLine() 78 | } 79 | } 80 | 81 | // bindCtx exists to allow kong to correctly inject a context.Context into the Run methods on the commands. 82 | func bindCtx(ctx context.Context) func() (context.Context, error) { 83 | return func() (context.Context, error) { 84 | return ctx, nil 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /internal/service/manager_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestDefaultK8s_KubeconfigHandling(t *testing.T) { 10 | // Save original KUBECONFIG env var 11 | originalKubeconfig := os.Getenv("KUBECONFIG") 12 | defer func() { 13 | os.Setenv("KUBECONFIG", originalKubeconfig) 14 | }() 15 | 16 | tests := []struct { 17 | name string 18 | kubecfg string 19 | kubectx string 20 | setupEnv func() 21 | wantErr bool 22 | errContains string 23 | }{ 24 | { 25 | name: "explicit kubeconfig path", 26 | kubecfg: "/tmp/test-kubeconfig", 27 | kubectx: "", 28 | setupEnv: func() { 29 | os.Unsetenv("KUBECONFIG") 30 | }, 31 | wantErr: true, 32 | errContains: "no such file or directory", 33 | }, 34 | { 35 | name: "empty kubeconfig uses default resolution", 36 | kubecfg: "", 37 | kubectx: "", 38 | setupEnv: func() { 39 | // Test with KUBECONFIG env var set 40 | tmpDir := t.TempDir() 41 | testConfig := filepath.Join(tmpDir, "test.config") 42 | os.Setenv("KUBECONFIG", testConfig) 43 | }, 44 | wantErr: true, 45 | errContains: "invalid configuration", 46 | }, 47 | { 48 | name: "empty kubeconfig with invalid KUBECONFIG env", 49 | kubecfg: "", 50 | kubectx: "", 51 | setupEnv: func() { 52 | // Set KUBECONFIG to a non-existent file 53 | os.Setenv("KUBECONFIG", "/tmp/nonexistent-kubeconfig-for-test") 54 | }, 55 | wantErr: true, 56 | errContains: "invalid configuration", 57 | }, 58 | { 59 | name: "explicit context override with non-existent context", 60 | kubecfg: "", 61 | kubectx: "custom-context", 62 | setupEnv: func() { 63 | // Set KUBECONFIG to a non-existent file 64 | os.Setenv("KUBECONFIG", "/tmp/nonexistent-kubeconfig-for-test") 65 | }, 66 | wantErr: true, 67 | errContains: "context \"custom-context\" does not exist", 68 | }, 69 | } 70 | 71 | for _, tt := range tests { 72 | t.Run(tt.name, func(t *testing.T) { 73 | tt.setupEnv() 74 | 75 | _, err := DefaultK8s(tt.kubecfg, tt.kubectx) 76 | 77 | if tt.wantErr { 78 | if err == nil { 79 | t.Errorf("DefaultK8s() error = nil, wantErr %v", tt.wantErr) 80 | return 81 | } 82 | if tt.errContains != "" && !contains(err.Error(), tt.errContains) { 83 | t.Errorf("DefaultK8s() error = %v, want error containing %v", err.Error(), tt.errContains) 84 | } 85 | return 86 | } 87 | 88 | if err != nil { 89 | t.Errorf("DefaultK8s() unexpected error = %v", err) 90 | } 91 | }) 92 | } 93 | } 94 | 95 | func contains(s, substr string) bool { 96 | return len(s) >= len(substr) && s[0:len(substr)] == substr || len(s) > len(substr) && contains(s[1:], substr) 97 | } -------------------------------------------------------------------------------- /internal/cmd/auth/switch-organization.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/airbytehq/abctl/internal/airbox" 10 | "github.com/airbytehq/abctl/internal/api" 11 | "github.com/airbytehq/abctl/internal/http" 12 | "github.com/airbytehq/abctl/internal/ui" 13 | ) 14 | 15 | // SwitchOrganizationCmd handles switching between orgs. 16 | type SwitchOrganizationCmd struct{} 17 | 18 | // Run executes the switch organization command. 19 | func (c *SwitchOrganizationCmd) Run(ctx context.Context, cfgStore airbox.ConfigStore, httpClient http.HTTPDoer, apiFactory airbox.APIServiceFactory, ui ui.Provider) error { 20 | ui.Title("Switching Workspace") 21 | ui.ShowInfo("Switch between your user's organizations.") 22 | ui.NewLine() 23 | 24 | apiClient, err := apiFactory(ctx, httpClient, cfgStore) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | // Load config for saving later 30 | cfg, err := cfgStore.Load() 31 | if err != nil { 32 | if errors.Is(err, os.ErrNotExist) { 33 | return airbox.NewConfigInitError("no airbox configuration found") 34 | } 35 | return fmt.Errorf("failed to load config: %w", err) 36 | } 37 | 38 | // Get current context for saving later 39 | currentContext, err := cfg.GetCurrentContext() 40 | if err != nil { 41 | return fmt.Errorf("could not get current context: %w", err) 42 | } 43 | 44 | // Get available orgs 45 | var org *api.Organization 46 | 47 | orgs, err := apiClient.ListOrganizations(ctx) 48 | if err != nil { 49 | return fmt.Errorf("failed to list orgs: %w", err) 50 | } 51 | 52 | switch len(orgs) { 53 | case 0: 54 | return fmt.Errorf("no organizations found") 55 | case 1: 56 | // Automatically select the single organization 57 | org = orgs[0] 58 | ui.ShowInfo(fmt.Sprintf("Setting organization to: %s", org.Name)) 59 | ui.NewLine() 60 | default: 61 | orgNames := make([]string, len(orgs)) 62 | for i, org := range orgs { 63 | orgNames[i] = org.Name 64 | } 65 | 66 | // Use filterable select for better UX when there are many organizations 67 | var idx int 68 | if len(orgs) > 10 { 69 | idx, _, err = ui.FilterableSelect("Select an organization", orgNames) 70 | } else { 71 | idx, _, err = ui.Select("Select an organization:", orgNames) 72 | } 73 | if err != nil { 74 | return err 75 | } 76 | 77 | org = orgs[idx] 78 | } 79 | 80 | currentContext.OrganizationID = org.ID 81 | 82 | cfg.AddContext(cfg.CurrentContext, *currentContext) 83 | if err := cfgStore.Save(cfg); err != nil { 84 | return fmt.Errorf("failed to save context: %w", err) 85 | } 86 | 87 | err = cfgStore.Save(cfg) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | ui.ShowSuccess("Organization switched successfully!") 93 | ui.NewLine() 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/k8s/provider_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/airbytehq/abctl/internal/paths" 11 | "github.com/google/go-cmp/cmp" 12 | ) 13 | 14 | func TestProvider_Defaults(t *testing.T) { 15 | t.Run("DefaultProvider", func(t *testing.T) { 16 | if d := cmp.Diff(Kind, DefaultProvider.Name); d != "" { 17 | t.Errorf("Name mismatch (-want +got):\n%s", d) 18 | } 19 | if d := cmp.Diff("airbyte-abctl", DefaultProvider.ClusterName); d != "" { 20 | t.Errorf("ClusterName mismatch (-want +got):\n%s", d) 21 | } 22 | if d := cmp.Diff("kind-airbyte-abctl", DefaultProvider.Context); d != "" { 23 | t.Errorf("Context mismatch (-want +got):\n%s", d) 24 | } 25 | if d := cmp.Diff(paths.Kubeconfig, DefaultProvider.Kubeconfig); d != "" { 26 | t.Errorf("Kubeconfig mismatch (-want +got):\n%s", d) 27 | } 28 | }) 29 | 30 | t.Run("test", func(t *testing.T) { 31 | if d := cmp.Diff(Test, TestProvider.Name); d != "" { 32 | t.Errorf("Name mismatch (-want +got):\n%s", d) 33 | } 34 | if d := cmp.Diff("test-airbyte-abctl", TestProvider.ClusterName); d != "" { 35 | t.Errorf("ClusterName mismatch (-want +got):\n%s", d) 36 | } 37 | if d := cmp.Diff("test-airbyte-abctl", TestProvider.Context); d != "" { 38 | t.Errorf("Context mismatch (-want +got):\n%s", d) 39 | } 40 | // the TestProvider uses a temporary directory, so verify 41 | // - filename is correct 42 | // - directory is not paths.Kubeconfig 43 | if d := cmp.Diff(paths.FileKubeconfig, filepath.Base(TestProvider.Kubeconfig)); d != "" { 44 | t.Errorf("Kubeconfig mismatch (-want +got):\n%s", d) 45 | } 46 | if d := cmp.Diff(paths.Kubeconfig, TestProvider.Kubeconfig); d == "" { 47 | t.Errorf("Kubeconfig should differ (%s)", paths.Kubeconfig) 48 | } 49 | }) 50 | } 51 | 52 | func TestProvider_Cluster(t *testing.T) { 53 | // go will reuse TempDir directories between runs, ensure it is clean before running this test 54 | if err := os.RemoveAll(filepath.Dir(TestProvider.Kubeconfig)); err != nil { 55 | t.Fatalf("failed to remove temp kubeconfig dir: %s", err) 56 | } 57 | 58 | if dirExists(filepath.Dir(TestProvider.Kubeconfig)) { 59 | t.Fatal("Kubeconfig should not exist") 60 | } 61 | 62 | cluster, err := TestProvider.Cluster(context.Background()) 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | 67 | if !dirExists(filepath.Dir(TestProvider.Kubeconfig)) { 68 | t.Error("Kubeconfig should exist") 69 | } 70 | 71 | if cluster == nil { 72 | t.Error("cluster should not be nil") 73 | } 74 | } 75 | 76 | func dirExists(dir string) bool { 77 | if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) { 78 | return false 79 | } else if err != nil { 80 | return false 81 | } 82 | 83 | return true 84 | } 85 | -------------------------------------------------------------------------------- /internal/airbox/factories.go: -------------------------------------------------------------------------------- 1 | package airbox 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/airbytehq/abctl/internal/api" 10 | "github.com/airbytehq/abctl/internal/auth" 11 | "github.com/airbytehq/abctl/internal/http" 12 | ) 13 | 14 | // APIServiceFactory creates authenticated API services with all boilerplate handled 15 | type APIServiceFactory func(ctx context.Context, httpClient http.HTTPDoer, cfg ConfigStore) (api.Service, error) 16 | 17 | // NewAPIService creates an authenticated API service with all boilerplate handled 18 | func NewAPIService(ctx context.Context, httpClient http.HTTPDoer, cfg ConfigStore) (api.Service, error) { 19 | // Load airbox config 20 | abCfg, err := cfg.Load() 21 | if err != nil { 22 | if errors.Is(err, os.ErrNotExist) { 23 | return nil, NewConfigInitError("no airbox configuration found") 24 | } 25 | return nil, fmt.Errorf("failed to load config: %w", err) 26 | } 27 | 28 | // Check credentials exist 29 | if abCfg.Credentials == nil { 30 | return nil, NewLoginError("no credentials") 31 | } 32 | 33 | // Get the current context set in the Airbox config 34 | currentContext, err := abCfg.GetCurrentContext() 35 | if err != nil { 36 | return nil, fmt.Errorf("no current context configured: %w", err) 37 | } 38 | 39 | // Create HTTP client with API base URL from config 40 | apiHTTPClient, err := http.NewClient(currentContext.AirbyteAPIURL, httpClient) 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to create HTTP client: %w", err) 43 | } 44 | 45 | // Check if auth is configured 46 | if currentContext.Auth.provider == nil { 47 | return nil, NewConfigInitError("no auth configured") 48 | } 49 | 50 | // Create auth provider based on auth type from config (NO HTTP CALLS) 51 | var authProvider auth.Provider 52 | switch currentContext.Auth.provider.Type() { 53 | case auth.OAuth2ProviderName: 54 | oauth, err := currentContext.Auth.GetOAuth2Provider() 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | // Create OAuth2 provider WITHOUT authenticating 60 | store := NewCredentialStoreAdapter(cfg) 61 | authProvider, err = auth.NewOAuth2Provider( 62 | ctx, 63 | oauth.ClientID, 64 | oauth.ClientSecret, 65 | apiHTTPClient, 66 | store, 67 | ) 68 | if err != nil { 69 | return nil, fmt.Errorf("failed to create OAuth2 provider: %w", err) 70 | } 71 | 72 | case auth.OIDCProviderName: 73 | // For OIDC, create provider with stored credentials 74 | store := NewCredentialStoreAdapter(cfg) 75 | authProvider = auth.NewOIDCProvider(apiHTTPClient, store) 76 | 77 | default: 78 | return nil, fmt.Errorf("unsupported auth type: %s", currentContext.Auth.provider.Type()) 79 | } 80 | 81 | // Create and return API service 82 | return api.NewClient(authProvider), nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/airbox/credentials_test.go: -------------------------------------------------------------------------------- 1 | package airbox 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/airbytehq/abctl/internal/auth" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewCredentialStoreAdapter(t *testing.T) { 11 | configStore := &FileConfigStore{} 12 | adapter := NewCredentialStoreAdapter(configStore) 13 | 14 | assert.NotNil(t, adapter) 15 | } 16 | 17 | func TestCredentialStoreAdapter_Load(t *testing.T) { 18 | tests := []struct { 19 | name string 20 | setupConfig func() *Config 21 | expectedCreds *auth.Credentials 22 | expectedError string 23 | }{ 24 | { 25 | name: "valid credentials", 26 | setupConfig: func() *Config { 27 | return &Config{ 28 | Credentials: &auth.Credentials{ 29 | AccessToken: "test-token", 30 | RefreshToken: "refresh-token", 31 | }, 32 | } 33 | }, 34 | expectedCreds: &auth.Credentials{ 35 | AccessToken: "test-token", 36 | RefreshToken: "refresh-token", 37 | }, 38 | }, 39 | { 40 | name: "no credentials", 41 | setupConfig: func() *Config { 42 | return &Config{ 43 | Credentials: nil, 44 | } 45 | }, 46 | expectedError: "no user credentials found", 47 | }, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | // Create mock config store 53 | mockStore := &mockConfigStore{ 54 | config: tt.setupConfig(), 55 | } 56 | 57 | adapter := NewCredentialStoreAdapter(mockStore) 58 | creds, err := adapter.Load() 59 | 60 | if tt.expectedError != "" { 61 | assert.Error(t, err) 62 | assert.Contains(t, err.Error(), tt.expectedError) 63 | assert.Nil(t, creds) 64 | } else { 65 | assert.NoError(t, err) 66 | assert.Equal(t, tt.expectedCreds, creds) 67 | } 68 | }) 69 | } 70 | } 71 | 72 | func TestCredentialStoreAdapter_Save(t *testing.T) { 73 | mockStore := &mockConfigStore{ 74 | config: &Config{ 75 | Credentials: nil, 76 | }, 77 | } 78 | 79 | adapter := NewCredentialStoreAdapter(mockStore) 80 | newCreds := &auth.Credentials{ 81 | AccessToken: "new-token", 82 | RefreshToken: "new-refresh", 83 | } 84 | 85 | err := adapter.Save(newCreds) 86 | assert.NoError(t, err) 87 | 88 | // Verify credentials were set in config 89 | assert.Equal(t, newCreds, mockStore.config.Credentials) 90 | } 91 | 92 | // mockConfigStore for testing credentials 93 | type mockConfigStore struct { 94 | config *Config 95 | err error 96 | } 97 | 98 | func (m *mockConfigStore) Load() (*Config, error) { 99 | return m.config, m.err 100 | } 101 | 102 | func (m *mockConfigStore) Save(*Config) error { 103 | return nil 104 | } 105 | 106 | func (m *mockConfigStore) GetPath() string { 107 | return "/tmp/test-config.yaml" 108 | } 109 | 110 | func (m *mockConfigStore) Exists() bool { 111 | return m.config != nil 112 | } 113 | -------------------------------------------------------------------------------- /internal/cmd/output_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "go.uber.org/mock/gomock" 9 | 10 | uimock "github.com/airbytehq/abctl/internal/ui/mock" 11 | ) 12 | 13 | func TestRenderOutput(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | format string 17 | data any 18 | setupMocks func(ctrl *gomock.Controller) *uimock.MockProvider 19 | expectedError string 20 | }{ 21 | { 22 | name: "default to JSON", 23 | format: "", 24 | data: map[string]string{"test": "data"}, 25 | setupMocks: func(ctrl *gomock.Controller) *uimock.MockProvider { 26 | ui := uimock.NewMockProvider(ctrl) 27 | ui.EXPECT().ShowJSON(map[string]string{"test": "data"}).Return(nil) 28 | return ui 29 | }, 30 | }, 31 | { 32 | name: "explicit JSON", 33 | format: "json", 34 | data: []string{"item1", "item2"}, 35 | setupMocks: func(ctrl *gomock.Controller) *uimock.MockProvider { 36 | ui := uimock.NewMockProvider(ctrl) 37 | ui.EXPECT().ShowJSON([]string{"item1", "item2"}).Return(nil) 38 | return ui 39 | }, 40 | }, 41 | { 42 | name: "YAML format", 43 | format: "yaml", 44 | data: struct{ Name string }{Name: "test"}, 45 | setupMocks: func(ctrl *gomock.Controller) *uimock.MockProvider { 46 | ui := uimock.NewMockProvider(ctrl) 47 | ui.EXPECT().ShowYAML(struct{ Name string }{Name: "test"}).Return(nil) 48 | return ui 49 | }, 50 | }, 51 | { 52 | name: "unsupported format", 53 | format: "xml", 54 | data: "test", 55 | setupMocks: func(ctrl *gomock.Controller) *uimock.MockProvider { return uimock.NewMockProvider(ctrl) }, 56 | expectedError: "unsupported output format: xml (supported: json, yaml)", 57 | }, 58 | { 59 | name: "JSON error", 60 | format: "json", 61 | data: "test", 62 | setupMocks: func(ctrl *gomock.Controller) *uimock.MockProvider { 63 | ui := uimock.NewMockProvider(ctrl) 64 | ui.EXPECT().ShowJSON("test").Return(errors.New("JSON error")) 65 | return ui 66 | }, 67 | expectedError: "JSON error", 68 | }, 69 | { 70 | name: "YAML error", 71 | format: "yaml", 72 | data: "test", 73 | setupMocks: func(ctrl *gomock.Controller) *uimock.MockProvider { 74 | ui := uimock.NewMockProvider(ctrl) 75 | ui.EXPECT().ShowYAML("test").Return(errors.New("YAML error")) 76 | return ui 77 | }, 78 | expectedError: "YAML error", 79 | }, 80 | } 81 | 82 | for _, tt := range tests { 83 | t.Run(tt.name, func(t *testing.T) { 84 | ctrl := gomock.NewController(t) 85 | defer ctrl.Finish() 86 | 87 | ui := tt.setupMocks(ctrl) 88 | err := RenderOutput(ui, tt.data, tt.format) 89 | 90 | if tt.expectedError != "" { 91 | assert.Error(t, err) 92 | assert.Contains(t, err.Error(), tt.expectedError) 93 | return 94 | } 95 | assert.NoError(t, err) 96 | }) 97 | } 98 | } -------------------------------------------------------------------------------- /internal/cmd/local/uninstall.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/airbytehq/abctl/internal/k8s" 8 | "github.com/airbytehq/abctl/internal/service" 9 | "github.com/airbytehq/abctl/internal/telemetry" 10 | "github.com/airbytehq/abctl/internal/trace" 11 | "github.com/pterm/pterm" 12 | "go.opentelemetry.io/otel/attribute" 13 | ) 14 | 15 | type UninstallCmd struct { 16 | Persisted bool `help:"Remove persisted data."` 17 | } 18 | 19 | func (u *UninstallCmd) Run(ctx context.Context, provider k8s.Provider, telClient telemetry.Client) error { 20 | ctx, span := trace.NewSpan(ctx, "local uninstall") 21 | defer span.End() 22 | 23 | span.SetAttributes(attribute.Bool("persisted", u.Persisted)) 24 | 25 | spinner := &pterm.DefaultSpinner 26 | spinner, _ = spinner.Start("Starting uninstallation") 27 | spinner.UpdateText("Checking for Docker installation") 28 | 29 | _, err := dockerInstalled(ctx, telClient) 30 | if err != nil { 31 | pterm.Error.Println("Unable to determine if Docker is installed") 32 | return fmt.Errorf("unable to determine docker installation status: %w", err) 33 | } 34 | 35 | return telClient.Wrap(ctx, telemetry.Uninstall, func() error { 36 | spinner.UpdateText(fmt.Sprintf("Checking for existing Kubernetes cluster '%s'", provider.ClusterName)) 37 | 38 | cluster, err := provider.Cluster(ctx) 39 | if err != nil { 40 | pterm.Error.Printfln("Unable to determine if the cluster '%s' exists", provider.ClusterName) 41 | return err 42 | } 43 | 44 | // if no cluster exists, there is nothing to do 45 | if !cluster.Exists(ctx) { 46 | pterm.Success.Printfln("Cluster '%s' does not exist\nNo additional action required", provider.ClusterName) 47 | return nil 48 | } 49 | 50 | pterm.Success.Printfln("Existing cluster '%s' found", provider.ClusterName) 51 | 52 | svcMgr, err := service.NewManager(provider, service.WithTelemetryClient(telClient), service.WithSpinner(spinner)) 53 | if err != nil { 54 | pterm.Warning.Printfln("Failed to initialize 'local' command\nUninstallation attempt will continue") 55 | pterm.Debug.Printfln("Initialization of 'local' failed with %s", err.Error()) 56 | } else { 57 | if err := svcMgr.Uninstall(ctx, service.UninstallOpts{Persisted: u.Persisted}); err != nil { 58 | pterm.Warning.Printfln("unable to complete uninstall: %s", err.Error()) 59 | pterm.Warning.Println("will still attempt to uninstall the cluster") 60 | } 61 | } 62 | 63 | spinner.UpdateText(fmt.Sprintf("Verifying uninstallation status of cluster '%s'", provider.ClusterName)) 64 | if err := cluster.Delete(ctx); err != nil { 65 | pterm.Error.Printfln("Uninstallation of cluster '%s' failed", provider.ClusterName) 66 | return fmt.Errorf("unable to uninstall cluster %s", provider.ClusterName) 67 | } 68 | pterm.Success.Printfln("Uninstallation of cluster '%s' completed successfully", provider.ClusterName) 69 | 70 | spinner.Success("Airbyte uninstallation complete") 71 | 72 | return nil 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /internal/airbyte/log_scanner.go: -------------------------------------------------------------------------------- 1 | package airbyte 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "io" 7 | ) 8 | 9 | // LogScanner 10 | type LogScanner struct { 11 | scanner *bufio.Scanner 12 | Line logLine 13 | } 14 | 15 | // NewLogScanner returns an initialized Airbyte log scanner. 16 | func NewLogScanner(r io.Reader) *LogScanner { 17 | return &LogScanner{ 18 | scanner: bufio.NewScanner(r), 19 | } 20 | } 21 | 22 | func (j *LogScanner) Scan() bool { 23 | for { 24 | if ok := j.scanner.Scan(); !ok { 25 | return false 26 | } 27 | 28 | var data logLine 29 | err := json.Unmarshal(j.scanner.Bytes(), &data) 30 | // not all lines are JSON. don't propogate errors, just include the full line. 31 | if err != nil { 32 | j.Line = logLine{Message: j.scanner.Text()} 33 | } else { 34 | j.Line = data 35 | } 36 | 37 | return true 38 | } 39 | } 40 | 41 | func (j *LogScanner) Err() error { 42 | return j.scanner.Err() 43 | } 44 | 45 | /* 46 | { 47 | "timestamp": 1734712334950, 48 | "message": "Unable to bootstrap Airbyte environment.", 49 | "level": "ERROR", 50 | "logSource": "platform", 51 | "caller": { 52 | "className": "io.airbyte.bootloader.Application", 53 | "methodName": "main", 54 | "lineNumber": 28, 55 | "threadName": "main" 56 | }, 57 | "throwable": { 58 | "cause": { 59 | "cause": null, 60 | "stackTrace": [ 61 | { 62 | "cn": "io.airbyte.bootloader.Application", 63 | "ln": 25, 64 | "mn": "main" 65 | } 66 | ], 67 | "message": "Unable to connect to the database.", 68 | "suppressed": [], 69 | "localizedMessage": "Unable to connect to the database." 70 | }, 71 | "stackTrace": [ 72 | { 73 | "cn": "io.airbyte.bootloader.Application", 74 | "ln": 25, 75 | "mn": "main" 76 | } 77 | ], 78 | "message": "Database availability check failed.", 79 | "suppressed": [], 80 | "localizedMessage": "Database availability check failed." 81 | } 82 | } 83 | */ 84 | type logLine struct { 85 | Timestamp int64 `json:"timestamp"` 86 | Message string `json:"message"` 87 | Level string `json:"level"` 88 | LogSource string `json:"logSource"` 89 | Caller *logCaller `json:"caller"` 90 | Throwable *logThrowable `json:throwable` 91 | } 92 | 93 | type logCaller struct { 94 | ClassName string `json:"className"` 95 | MethodName string `json:"methodName"` 96 | LineNumber int `json:"lineNumber"` 97 | ThreadName string `json:"threadName"` 98 | } 99 | 100 | type logStackElement struct { 101 | ClassName string `json:"cn"` 102 | LineNumber int `json:"ln"` 103 | MethodName string `json:"mn"` 104 | } 105 | 106 | type logThrowable struct { 107 | Cause *logThrowable `json:"cause"` 108 | Stacktrace []logStackElement `json:"stackTrace"` 109 | Message string `json:"message"` 110 | } 111 | -------------------------------------------------------------------------------- /internal/helm/dataplane.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/airbytehq/abctl/internal/airbox" 9 | "github.com/airbytehq/abctl/internal/api" 10 | "github.com/airbytehq/abctl/internal/common" 11 | goHelm "github.com/mittwald/go-helm-client" 12 | "helm.sh/helm/v3/pkg/repo" 13 | ) 14 | 15 | const ( 16 | dataplaneChartName = "airbyte/airbyte-data-plane" 17 | dataplaneChartVersion = "1.8.1" 18 | dataplaneRepoName = "airbyte" 19 | dataplaneRepoURL = common.AirbyteRepoURLv1 // v1 repo has the dataplane chart 20 | ) 21 | 22 | // InstallDataplaneChart installs the official Airbyte dataplane Helm chart 23 | func InstallDataplaneChart(ctx context.Context, client goHelm.Client, namespace, releaseName string, credentials *api.CreateDataplaneResponse, config *airbox.Context) error { 24 | // Add the Airbyte Helm repository 25 | if err := client.AddOrUpdateChartRepo(repo.Entry{ 26 | Name: dataplaneRepoName, 27 | URL: dataplaneRepoURL, 28 | }); err != nil { 29 | return fmt.Errorf("failed to add Airbyte chart repository: %w", err) 30 | } 31 | 32 | // Build values for the dataplane chart 33 | valuesYAML := buildDataplaneValues(credentials, config) 34 | 35 | // Install the chart with atomic flag to ensure cleanup on failure/interrupt 36 | _, err := client.InstallOrUpgradeChart(ctx, &goHelm.ChartSpec{ 37 | ReleaseName: releaseName, 38 | ChartName: dataplaneChartName, 39 | Version: dataplaneChartVersion, 40 | CreateNamespace: true, 41 | Namespace: namespace, 42 | Wait: true, 43 | Atomic: true, // Rollback on failure, including Ctrl-C 44 | Timeout: 10 * time.Minute, 45 | ValuesYaml: valuesYAML, 46 | }, &goHelm.GenericHelmOptions{}) 47 | if err != nil { 48 | return fmt.Errorf("failed to install dataplane chart: %w", err) 49 | } 50 | 51 | return nil 52 | } 53 | 54 | // buildDataplaneValues constructs the Helm values YAML for the dataplane chart 55 | func buildDataplaneValues(credentials *api.CreateDataplaneResponse, config *airbox.Context) string { 56 | return fmt.Sprintf(`# Airbyte Dataplane Configuration 57 | # Set the Airbyte Control Plane URL 58 | airbyteUrl: "%s" 59 | 60 | # Workload API server configuration for remote dataplane 61 | workloadApiServer: 62 | url: "%s" 63 | 64 | dataPlane: 65 | id: "%s" 66 | clientId: "%s" 67 | clientSecret: "%s" 68 | 69 | # Configure for dataplane mode 70 | edition: "%s" 71 | 72 | # Storage configuration (using default Minio for now) 73 | storage: 74 | type: minio 75 | minio: 76 | accessKeyId: minio 77 | secretAccessKey: "minio123" 78 | bucket: 79 | log: airbyte-bucket 80 | state: airbyte-bucket 81 | workloadOutput: airbyte-bucket 82 | activityPayload: airbyte-bucket 83 | 84 | # Workloads configuration 85 | workloads: 86 | namespace: "jobs" 87 | 88 | # Logging configuration 89 | logging: 90 | level: info 91 | 92 | # Metrics disabled by default 93 | metrics: 94 | enabled: "false" 95 | `, config.AirbyteURL, config.AirbyteAPIURL, credentials.DataplaneID, credentials.ClientID, credentials.ClientSecret, config.Edition) 96 | } 97 | -------------------------------------------------------------------------------- /internal/telemetry/client_test.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestGet(t *testing.T) { 13 | instance = nil 14 | home := t.TempDir() 15 | 16 | cli := Get(WithUserHome(home)) 17 | if _, ok := cli.(*SegmentClient); !ok { 18 | t.Error(fmt.Sprintf("expected SegmentClient; received: %T", cli)) 19 | } 20 | 21 | // verify configuration file was created 22 | data, err := os.ReadFile(filepath.Join(home, ConfigFile)) 23 | if err != nil { 24 | t.Error("reading config file", err) 25 | } 26 | 27 | // and has some data 28 | if !strings.Contains(string(data), "Airbyte") { 29 | t.Error("expected config file to contain 'Airbyte'") 30 | } 31 | 32 | if !strings.Contains(string(data), fieldAnalyticsID) { 33 | t.Error(fmt.Sprintf("expected config file to contain '%s'", fieldAnalyticsID)) 34 | } 35 | 36 | if strings.Contains(string(data), fieldUserID) { 37 | t.Error(fmt.Sprintf("config file should not contain '%s'", fieldUserID)) 38 | } 39 | } 40 | 41 | func TestGet_WithExistingULID(t *testing.T) { 42 | instance = nil 43 | home := t.TempDir() 44 | 45 | // write a config with a ulid only 46 | cfg := Config{UserID: NewULID()} 47 | if err := writeConfigToFile(filepath.Join(home, ConfigFile), cfg); err != nil { 48 | t.Fatal("failed writing config", err) 49 | } 50 | 51 | cli := Get(WithUserHome(home)) 52 | if _, ok := cli.(*SegmentClient); !ok { 53 | t.Error(fmt.Sprintf("expected SegmentClient; received: %T", cli)) 54 | } 55 | 56 | // verify configuration file was created 57 | data, err := os.ReadFile(filepath.Join(home, ConfigFile)) 58 | if err != nil { 59 | t.Error("reading config file", err) 60 | } 61 | 62 | // and has some data 63 | if !strings.Contains(string(data), "Airbyte") { 64 | t.Error("expected config file to contain 'Airbyte'") 65 | } 66 | 67 | if !strings.Contains(string(data), fieldAnalyticsID) { 68 | t.Error(fmt.Sprintf("expected config file to contain '%s'", fieldAnalyticsID)) 69 | } 70 | 71 | if !strings.Contains(string(data), fieldUserID) { 72 | t.Error(fmt.Sprintf("config file should not contain '%s'", fieldUserID)) 73 | } 74 | } 75 | 76 | func TestGet_SameInstance(t *testing.T) { 77 | instance = nil 78 | home := t.TempDir() 79 | cli1 := Get(WithUserHome(home)) 80 | cli2 := Get(WithUserHome(home)) 81 | cli3 := Get() 82 | cli4 := Get(WithDNT()) 83 | 84 | if cli1 != cli2 { 85 | t.Error("expected same client") 86 | } 87 | if cli1 != cli3 { 88 | t.Error("expected same client") 89 | } 90 | if cli1 != cli4 { 91 | t.Error("expected same client") 92 | } 93 | } 94 | 95 | func TestGet_Dnt(t *testing.T) { 96 | instance = nil 97 | home := t.TempDir() 98 | cli := Get(WithUserHome(home), WithDNT()) 99 | 100 | if _, ok := cli.(NoopClient); !ok { 101 | t.Error(fmt.Sprintf("expected NoopClient; received: %T", cli)) 102 | } 103 | 104 | // no configuration file was created 105 | _, err := os.ReadFile(filepath.Join(home, ConfigFile)) 106 | if !errors.Is(err, os.ErrNotExist) { 107 | t.Error("expected file not exists", err) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /internal/cmd/local/deployments_test.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/airbytehq/abctl/internal/k8s/k8stest" 12 | "github.com/google/go-cmp/cmp" 13 | "github.com/google/go-cmp/cmp/cmpopts" 14 | "github.com/pterm/pterm" 15 | v1 "k8s.io/api/apps/v1" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | ) 18 | 19 | func TestDeploymentsCmd(t *testing.T) { 20 | b := bytes.NewBufferString("") 21 | pterm.Info.Writer = b 22 | // SetDefaultOutput isn't overriding the Info.Writer 23 | pterm.SetDefaultOutput(b) 24 | pterm.EnableDebugMessages() 25 | pterm.DisableColor() 26 | t.Cleanup(func() { 27 | pterm.Info.Writer = os.Stdout 28 | pterm.SetDefaultOutput(os.Stdout) 29 | pterm.DisableDebugMessages() 30 | pterm.EnableColor() 31 | }) 32 | 33 | ctx := context.Background() 34 | t.Run("two deployments", func(t *testing.T) { 35 | b.Reset() 36 | 37 | mockK8s := &k8stest.MockClient{ 38 | FnDeploymentList: func(ctx context.Context, namespace string) (*v1.DeploymentList, error) { 39 | if d := cmp.Diff(airbyteNamespace, namespace); d != "" { 40 | t.Errorf("unexpected namespace:\n%s", d) 41 | } 42 | 43 | return &v1.DeploymentList{ 44 | Items: []v1.Deployment{ 45 | {ObjectMeta: metav1.ObjectMeta{Name: "deployment0"}}, 46 | {ObjectMeta: metav1.ObjectMeta{Name: "deployment1"}}, 47 | }, 48 | }, nil 49 | }, 50 | } 51 | 52 | cmd := &DeploymentsCmd{} 53 | err := cmd.deployments(ctx, mockK8s, &pterm.DefaultSpinner) 54 | if err != nil { 55 | t.Fatal("unexpected error", err) 56 | } 57 | 58 | if !strings.Contains(b.String(), "deployment0") { 59 | t.Error("missing deployment0 from output") 60 | } 61 | if !strings.Contains(b.String(), "deployment1") { 62 | t.Error("missing deployment1 from output") 63 | } 64 | }) 65 | 66 | t.Run("no deployments", func(t *testing.T) { 67 | b.Reset() 68 | 69 | mockK8s := &k8stest.MockClient{ 70 | FnDeploymentList: func(ctx context.Context, namespace string) (*v1.DeploymentList, error) { 71 | if d := cmp.Diff(airbyteNamespace, namespace); d != "" { 72 | t.Errorf("unexpected namespace:\n%s", d) 73 | } 74 | 75 | return &v1.DeploymentList{Items: []v1.Deployment{}}, nil 76 | }, 77 | } 78 | 79 | cmd := &DeploymentsCmd{} 80 | err := cmd.deployments(ctx, mockK8s, &pterm.DefaultSpinner) 81 | if err != nil { 82 | t.Fatal("unexpected error", err) 83 | } 84 | 85 | if !strings.Contains(b.String(), "No deployments found") { 86 | t.Error("missing 'No deployments found' from output") 87 | } 88 | }) 89 | 90 | t.Run("error", func(t *testing.T) { 91 | b.Reset() 92 | 93 | errTest := errors.New("test error") 94 | mockK8s := &k8stest.MockClient{ 95 | FnDeploymentList: func(ctx context.Context, namespace string) (*v1.DeploymentList, error) { 96 | return nil, errTest 97 | }, 98 | } 99 | 100 | cmd := &DeploymentsCmd{} 101 | err := cmd.deployments(ctx, mockK8s, &pterm.DefaultSpinner) 102 | if d := cmp.Diff(errTest, err, cmpopts.EquateErrors()); d != "" { 103 | t.Errorf("error mismatch (-want +got):\n%s", d) 104 | } 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /internal/airbox/mock/config.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/airbox/config_store.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen --source internal/airbox/config_store.go -destination internal/airbox/mock/config.go -package mock 7 | // 8 | 9 | // Package mock is a generated GoMock package. 10 | package mock 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | airbox "github.com/airbytehq/abctl/internal/airbox" 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockConfigStore is a mock of ConfigStore interface. 20 | type MockConfigStore struct { 21 | ctrl *gomock.Controller 22 | recorder *MockConfigStoreMockRecorder 23 | isgomock struct{} 24 | } 25 | 26 | // MockConfigStoreMockRecorder is the mock recorder for MockConfigStore. 27 | type MockConfigStoreMockRecorder struct { 28 | mock *MockConfigStore 29 | } 30 | 31 | // NewMockConfigStore creates a new mock instance. 32 | func NewMockConfigStore(ctrl *gomock.Controller) *MockConfigStore { 33 | mock := &MockConfigStore{ctrl: ctrl} 34 | mock.recorder = &MockConfigStoreMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockConfigStore) EXPECT() *MockConfigStoreMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // Exists mocks base method. 44 | func (m *MockConfigStore) Exists() bool { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "Exists") 47 | ret0, _ := ret[0].(bool) 48 | return ret0 49 | } 50 | 51 | // Exists indicates an expected call of Exists. 52 | func (mr *MockConfigStoreMockRecorder) Exists() *gomock.Call { 53 | mr.mock.ctrl.T.Helper() 54 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exists", reflect.TypeOf((*MockConfigStore)(nil).Exists)) 55 | } 56 | 57 | // GetPath mocks base method. 58 | func (m *MockConfigStore) GetPath() string { 59 | m.ctrl.T.Helper() 60 | ret := m.ctrl.Call(m, "GetPath") 61 | ret0, _ := ret[0].(string) 62 | return ret0 63 | } 64 | 65 | // GetPath indicates an expected call of GetPath. 66 | func (mr *MockConfigStoreMockRecorder) GetPath() *gomock.Call { 67 | mr.mock.ctrl.T.Helper() 68 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPath", reflect.TypeOf((*MockConfigStore)(nil).GetPath)) 69 | } 70 | 71 | // Load mocks base method. 72 | func (m *MockConfigStore) Load() (*airbox.Config, error) { 73 | m.ctrl.T.Helper() 74 | ret := m.ctrl.Call(m, "Load") 75 | ret0, _ := ret[0].(*airbox.Config) 76 | ret1, _ := ret[1].(error) 77 | return ret0, ret1 78 | } 79 | 80 | // Load indicates an expected call of Load. 81 | func (mr *MockConfigStoreMockRecorder) Load() *gomock.Call { 82 | mr.mock.ctrl.T.Helper() 83 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockConfigStore)(nil).Load)) 84 | } 85 | 86 | // Save mocks base method. 87 | func (m *MockConfigStore) Save(config *airbox.Config) error { 88 | m.ctrl.T.Helper() 89 | ret := m.ctrl.Call(m, "Save", config) 90 | ret0, _ := ret[0].(error) 91 | return ret0 92 | } 93 | 94 | // Save indicates an expected call of Save. 95 | func (mr *MockConfigStoreMockRecorder) Save(config any) *gomock.Call { 96 | mr.mock.ctrl.T.Helper() 97 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockConfigStore)(nil).Save), config) 98 | } 99 | -------------------------------------------------------------------------------- /internal/helm/images.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "context" 5 | "slices" 6 | "strings" 7 | 8 | goHelm "github.com/mittwald/go-helm-client" 9 | "helm.sh/helm/v3/pkg/repo" 10 | appsv1 "k8s.io/api/apps/v1" 11 | batchv1 "k8s.io/api/batch/v1" 12 | corev1 "k8s.io/api/core/v1" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | "k8s.io/kubectl/pkg/scheme" 15 | 16 | "github.com/airbytehq/abctl/internal/common" 17 | ) 18 | 19 | func FindImagesFromChart(client goHelm.Client, valuesYaml, chartName, chartVersion string) ([]string, error) { 20 | repoURL := common.AirbyteRepoURLv1 21 | if ChartIsV2Plus(chartVersion) { 22 | repoURL = common.AirbyteRepoURLv2 23 | } 24 | 25 | err := client.AddOrUpdateChartRepo(repo.Entry{ 26 | Name: common.AirbyteRepoName, 27 | URL: repoURL, 28 | }) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | rel, err := client.InstallChart(context.TODO(), &goHelm.ChartSpec{ 34 | ChartName: chartName, 35 | GenerateName: true, 36 | ValuesYaml: valuesYaml, 37 | Version: chartVersion, 38 | DryRun: true, 39 | }, nil) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | // Combine main manifest with hook manifests to include resources like bootloader 45 | fullManifest := rel.Manifest 46 | for _, hook := range rel.Hooks { 47 | fullManifest += "\n---\n" + hook.Manifest 48 | } 49 | 50 | images := findAllImages(fullManifest) 51 | return images, nil 52 | } 53 | 54 | // findAllImages walks through the Helm chart, looking for container images in k8s PodSpecs. 55 | // It also looks for env vars in the airbyte-env config map that end with "_IMAGE". 56 | // It returns a unique, sorted list of images found. 57 | func findAllImages(chartYaml string) []string { 58 | objs := decodeK8sResources(chartYaml) 59 | imageSet := common.Set[string]{} 60 | 61 | for _, obj := range objs { 62 | 63 | var podSpec *corev1.PodSpec 64 | switch z := obj.(type) { 65 | case *corev1.ConfigMap: 66 | if strings.HasSuffix(z.Name, "airbyte-env") { 67 | for k, v := range z.Data { 68 | if strings.HasSuffix(k, "_IMAGE") { 69 | imageSet.Add(v) 70 | } 71 | } 72 | } 73 | continue 74 | case *corev1.Pod: 75 | podSpec = &z.Spec 76 | case *batchv1.Job: 77 | podSpec = &z.Spec.Template.Spec 78 | case *appsv1.Deployment: 79 | podSpec = &z.Spec.Template.Spec 80 | case *appsv1.StatefulSet: 81 | podSpec = &z.Spec.Template.Spec 82 | default: 83 | continue 84 | } 85 | 86 | for _, c := range podSpec.InitContainers { 87 | imageSet.Add(c.Image) 88 | } 89 | for _, c := range podSpec.Containers { 90 | imageSet.Add(c.Image) 91 | } 92 | } 93 | 94 | var out []string 95 | for _, k := range imageSet.Items() { 96 | if k != "" { 97 | out = append(out, k) 98 | } 99 | } 100 | slices.Sort(out) 101 | 102 | return out 103 | } 104 | 105 | func decodeK8sResources(renderedYaml string) []runtime.Object { 106 | out := []runtime.Object{} 107 | chunks := strings.Split(renderedYaml, "---") 108 | for _, chunk := range chunks { 109 | if len(chunk) == 0 { 110 | continue 111 | } 112 | obj, _, err := scheme.Codecs.UniversalDeserializer().Decode([]byte(chunk), nil, nil) 113 | if err != nil { 114 | continue 115 | } 116 | out = append(out, obj) 117 | } 118 | return out 119 | } 120 | -------------------------------------------------------------------------------- /internal/ui/json_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestBubbleteaUI_ShowJSON(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | data any 15 | expectedOutput string 16 | expectedError string 17 | }{ 18 | { 19 | name: "simple struct", 20 | data: struct { 21 | Name string `json:"name"` 22 | Age int `json:"age"` 23 | }{ 24 | Name: "test", 25 | Age: 25, 26 | }, 27 | expectedOutput: `{ 28 | "name": "test", 29 | "age": 25 30 | } 31 | `, 32 | }, 33 | { 34 | name: "nested struct", 35 | data: struct { 36 | User struct { 37 | ID string `json:"id"` 38 | Name string `json:"name"` 39 | } `json:"user"` 40 | Active bool `json:"active"` 41 | }{ 42 | User: struct { 43 | ID string `json:"id"` 44 | Name string `json:"name"` 45 | }{ 46 | ID: "123", 47 | Name: "test", 48 | }, 49 | Active: true, 50 | }, 51 | expectedOutput: `{ 52 | "user": { 53 | "id": "123", 54 | "name": "test" 55 | }, 56 | "active": true 57 | } 58 | `, 59 | }, 60 | { 61 | name: "slice of structs", 62 | data: []struct { 63 | ID int `json:"id"` 64 | Name string `json:"name"` 65 | }{ 66 | {ID: 1, Name: "first"}, 67 | {ID: 2, Name: "second"}, 68 | }, 69 | expectedOutput: `[ 70 | { 71 | "id": 1, 72 | "name": "first" 73 | }, 74 | { 75 | "id": 2, 76 | "name": "second" 77 | } 78 | ] 79 | `, 80 | }, 81 | { 82 | name: "map data", 83 | data: map[string]any{ 84 | "key1": "value1", 85 | "key2": 42, 86 | "key3": true, 87 | }, 88 | expectedOutput: `{ 89 | "key1": "value1", 90 | "key2": 42, 91 | "key3": true 92 | } 93 | `, 94 | }, 95 | { 96 | name: "nil data", 97 | data: nil, 98 | expectedOutput: "null\n", 99 | }, 100 | { 101 | name: "empty slice", 102 | data: []string{}, 103 | expectedOutput: "[]\n", 104 | }, 105 | { 106 | name: "unmarshalable type", 107 | data: make(chan int), 108 | expectedError: "failed to marshal JSON", 109 | }, 110 | } 111 | 112 | for _, tt := range tests { 113 | t.Run(tt.name, func(t *testing.T) { 114 | stdout := &bytes.Buffer{} 115 | stderr := &bytes.Buffer{} 116 | ui := NewWithOptions(stdout, stderr, nil) 117 | 118 | err := ui.ShowJSON(tt.data) 119 | 120 | if tt.expectedError != "" { 121 | assert.Error(t, err) 122 | assert.Contains(t, err.Error(), tt.expectedError) 123 | } else { 124 | assert.NoError(t, err) 125 | // For maps, we need to handle non-deterministic ordering 126 | if strings.Contains(tt.name, "map") { 127 | // Just check that all expected lines are present 128 | actualLines := strings.Split(stdout.String(), "\n") 129 | expectedLines := strings.Split(tt.expectedOutput, "\n") 130 | assert.Equal(t, len(expectedLines), len(actualLines)) 131 | for _, line := range expectedLines { 132 | assert.Contains(t, stdout.String(), strings.TrimSpace(line)) 133 | } 134 | } else { 135 | assert.Equal(t, tt.expectedOutput, stdout.String()) 136 | } 137 | assert.Empty(t, stderr.String()) 138 | } 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /internal/helm/locate.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "golang.org/x/mod/semver" 9 | "helm.sh/helm/v3/pkg/cli" 10 | "helm.sh/helm/v3/pkg/getter" 11 | "helm.sh/helm/v3/pkg/repo" 12 | ) 13 | 14 | // chartRepo exists only for testing purposes. 15 | // This allows the DownloadIndexFile method to be mocked. 16 | type chartRepo interface { 17 | DownloadIndexFile() (string, error) 18 | } 19 | 20 | var _ chartRepo = (*repo.ChartRepository)(nil) 21 | 22 | // newChartRepo exists only for testing purposes. 23 | // This allows a test implementation of the repo.NewChartRepository function to exist. 24 | type newChartRepo func(cfg *repo.Entry, getters getter.Providers) (chartRepo, error) 25 | 26 | // loadIndexFile exists only for testing purposes. 27 | // This allows a test implementation of the repo.LoadIndexFile function to exist. 28 | type loadIndexFile func(path string) (*repo.IndexFile, error) 29 | 30 | // defaultNewChartRepo is the default implementation of the newChartRepo function. 31 | // It simply wraps the repo.NewChartRepository function. 32 | // This variable should only be modified for testing purposes. 33 | var defaultNewChartRepo newChartRepo = func(cfg *repo.Entry, getters getter.Providers) (chartRepo, error) { 34 | return repo.NewChartRepository(cfg, getters) 35 | } 36 | 37 | // defaultLoadIndexFile is the default implementation of the loadIndexFile function. 38 | // It simply wraps the repo.LoadIndexFile function. 39 | // This variable should only be modified for testing purposes. 40 | var defaultLoadIndexFile loadIndexFile = repo.LoadIndexFile 41 | 42 | // GetLatestAirbyteChartUrlFromRepoIndex fetches the latest stable Airbyte Helm chart URL and version 43 | // from the given Helm repository index. Returns the chart download URL, the chart version, and an error if any. 44 | // Only stable (non-prerelease) versions are considered. 45 | func GetLatestAirbyteChartUrlFromRepoIndex(repoName, repoUrl string) (string, string, error) { 46 | chartRepository, err := defaultNewChartRepo(&repo.Entry{ 47 | Name: repoName, 48 | URL: repoUrl, 49 | }, getter.All(cli.New())) 50 | if err != nil { 51 | return "", "", fmt.Errorf("unable to access repo index: %w", err) 52 | } 53 | 54 | idxPath, err := chartRepository.DownloadIndexFile() 55 | if err != nil { 56 | return "", "", fmt.Errorf("unable to download index file: %w", err) 57 | } 58 | 59 | idx, err := defaultLoadIndexFile(idxPath) 60 | if err != nil { 61 | return "", "", fmt.Errorf("unable to load index file (%s): %w", idxPath, err) 62 | } 63 | 64 | entries, ok := idx.Entries["airbyte"] 65 | if !ok { 66 | return "", "", fmt.Errorf("no entry for airbyte in repo index") 67 | } 68 | 69 | if len(entries) == 0 { 70 | return "", "", errors.New("no chart version found") 71 | } 72 | 73 | var latest *repo.ChartVersion 74 | for _, entry := range entries { 75 | version := entry.Version 76 | // the semver library requires a `v` prefix 77 | if !strings.HasPrefix(version, "v") { 78 | version = "v" + version 79 | } 80 | 81 | if semver.Prerelease(version) == "" { 82 | latest = entry 83 | break 84 | } 85 | } 86 | 87 | if latest == nil { 88 | return "", "", fmt.Errorf("no valid version of airbyte chart found in repo index") 89 | } 90 | 91 | if len(latest.URLs) != 1 { 92 | return "", "", fmt.Errorf("unexpected number of URLs - %d", len(latest.URLs)) 93 | } 94 | 95 | return repoUrl + "/" + latest.URLs[0], latest.Version, nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/build/build_test.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "runtime/debug" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestVersion(t *testing.T) { 11 | // struct definition 12 | // name: name of the sub-test 13 | // buildInfoFunc: test function to replace the default readBuildInfo function pointer 14 | // versionFunc: test function for updating the default Version value 15 | // exp: expected value that Version should be at the end 16 | tests := []struct { 17 | name string 18 | versionFunc func() 19 | buildInfoFunc buildInfoFunc 20 | exp string 21 | }{ 22 | { 23 | name: "default", 24 | exp: "dev", 25 | }, 26 | { 27 | name: "no v prefix", 28 | versionFunc: func() { Version = "9.8.7" }, 29 | exp: "v9.8.7", 30 | }, 31 | { 32 | name: "v prefix", 33 | versionFunc: func() { Version = "v3.1.4" }, 34 | exp: "v3.1.4", 35 | }, 36 | { 37 | name: "non-ok BuildInfo", 38 | buildInfoFunc: func() (*debug.BuildInfo, bool) { return nil, false }, 39 | exp: "dev", 40 | }, 41 | { 42 | name: "version non-dev, BuildInfo ignored", 43 | versionFunc: func() { Version = "5.5.5" }, 44 | buildInfoFunc: func() (*debug.BuildInfo, bool) { 45 | return &debug.BuildInfo{Main: debug.Module{ 46 | Version: "v9.9.9", 47 | }}, true 48 | }, 49 | exp: "v5.5.5", 50 | }, 51 | { 52 | name: "version dev, BuildInfo honored", 53 | buildInfoFunc: func() (*debug.BuildInfo, bool) { 54 | return &debug.BuildInfo{Main: debug.Module{ 55 | Version: "v9.9.9", 56 | }}, true 57 | }, 58 | exp: "v9.9.9", 59 | }, 60 | { 61 | name: "invalid version defined", 62 | versionFunc: func() { Version = "bad.version" }, 63 | exp: "invalid (bad.version)", 64 | }, 65 | { 66 | name: "invalid version from buildInfo", 67 | buildInfoFunc: func() (*debug.BuildInfo, bool) { 68 | return &debug.BuildInfo{Main: debug.Module{ 69 | Version: "BAD_BUILD", 70 | }}, true 71 | }, 72 | exp: "invalid (BAD_BUILD)", 73 | }, 74 | { 75 | name: "invalid version defined with v", 76 | versionFunc: func() { Version = "vbad.version" }, 77 | exp: "invalid (vbad.version)", 78 | }, 79 | { 80 | name: "invalid version from buildInfo with v", 81 | buildInfoFunc: func() (*debug.BuildInfo, bool) { 82 | return &debug.BuildInfo{Main: debug.Module{ 83 | Version: "VBAD_BUILD", 84 | }}, true 85 | }, 86 | exp: "invalid (VBAD_BUILD)", 87 | }, 88 | { 89 | name: "(devel) version is ignored", 90 | buildInfoFunc: func() (*debug.BuildInfo, bool) { 91 | return &debug.BuildInfo{Main: debug.Module{ 92 | Version: "(devel)", 93 | }}, true 94 | }, 95 | exp: "dev", 96 | }, 97 | } 98 | 99 | origReadBuildInfo := readBuildInfo 100 | origVersion := Version 101 | for _, tt := range tests { 102 | t.Run(tt.name, func(t *testing.T) { 103 | t.Cleanup(func() { readBuildInfo = origReadBuildInfo }) 104 | if tt.buildInfoFunc != nil { 105 | readBuildInfo = tt.buildInfoFunc 106 | Version = origVersion 107 | } 108 | if tt.versionFunc != nil { 109 | tt.versionFunc() 110 | } 111 | setVersion() 112 | 113 | if d := cmp.Diff(tt.exp, Version); d != "" { 114 | t.Error("Version differed (-want, +got):", d) 115 | } 116 | }) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /internal/k8s/mock/cluster.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/k8s/cluster.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen --source internal/k8s/cluster.go -destination internal/k8s/mock/cluster.go -package mock 7 | // 8 | 9 | // Package mock is a generated GoMock package. 10 | package mock 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | 16 | docker "github.com/airbytehq/abctl/internal/docker" 17 | k8s "github.com/airbytehq/abctl/internal/k8s" 18 | gomock "go.uber.org/mock/gomock" 19 | ) 20 | 21 | // MockCluster is a mock of Cluster interface. 22 | type MockCluster struct { 23 | ctrl *gomock.Controller 24 | recorder *MockClusterMockRecorder 25 | isgomock struct{} 26 | } 27 | 28 | // MockClusterMockRecorder is the mock recorder for MockCluster. 29 | type MockClusterMockRecorder struct { 30 | mock *MockCluster 31 | } 32 | 33 | // NewMockCluster creates a new mock instance. 34 | func NewMockCluster(ctrl *gomock.Controller) *MockCluster { 35 | mock := &MockCluster{ctrl: ctrl} 36 | mock.recorder = &MockClusterMockRecorder{mock} 37 | return mock 38 | } 39 | 40 | // EXPECT returns an object that allows the caller to indicate expected use. 41 | func (m *MockCluster) EXPECT() *MockClusterMockRecorder { 42 | return m.recorder 43 | } 44 | 45 | // Create mocks base method. 46 | func (m *MockCluster) Create(ctx context.Context, portHTTP int, extraMounts []k8s.ExtraVolumeMount) error { 47 | m.ctrl.T.Helper() 48 | ret := m.ctrl.Call(m, "Create", ctx, portHTTP, extraMounts) 49 | ret0, _ := ret[0].(error) 50 | return ret0 51 | } 52 | 53 | // Create indicates an expected call of Create. 54 | func (mr *MockClusterMockRecorder) Create(ctx, portHTTP, extraMounts any) *gomock.Call { 55 | mr.mock.ctrl.T.Helper() 56 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockCluster)(nil).Create), ctx, portHTTP, extraMounts) 57 | } 58 | 59 | // Delete mocks base method. 60 | func (m *MockCluster) Delete(ctx context.Context) error { 61 | m.ctrl.T.Helper() 62 | ret := m.ctrl.Call(m, "Delete", ctx) 63 | ret0, _ := ret[0].(error) 64 | return ret0 65 | } 66 | 67 | // Delete indicates an expected call of Delete. 68 | func (mr *MockClusterMockRecorder) Delete(ctx any) *gomock.Call { 69 | mr.mock.ctrl.T.Helper() 70 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockCluster)(nil).Delete), ctx) 71 | } 72 | 73 | // Exists mocks base method. 74 | func (m *MockCluster) Exists(ctx context.Context) bool { 75 | m.ctrl.T.Helper() 76 | ret := m.ctrl.Call(m, "Exists", ctx) 77 | ret0, _ := ret[0].(bool) 78 | return ret0 79 | } 80 | 81 | // Exists indicates an expected call of Exists. 82 | func (mr *MockClusterMockRecorder) Exists(ctx any) *gomock.Call { 83 | mr.mock.ctrl.T.Helper() 84 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exists", reflect.TypeOf((*MockCluster)(nil).Exists), ctx) 85 | } 86 | 87 | // LoadImages mocks base method. 88 | func (m *MockCluster) LoadImages(ctx context.Context, dockerClient docker.Client, images []string) { 89 | m.ctrl.T.Helper() 90 | m.ctrl.Call(m, "LoadImages", ctx, dockerClient, images) 91 | } 92 | 93 | // LoadImages indicates an expected call of LoadImages. 94 | func (mr *MockClusterMockRecorder) LoadImages(ctx, dockerClient, images any) *gomock.Call { 95 | mr.mock.ctrl.T.Helper() 96 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadImages", reflect.TypeOf((*MockCluster)(nil).LoadImages), ctx, dockerClient, images) 97 | } 98 | -------------------------------------------------------------------------------- /internal/k8s/provider.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/airbytehq/abctl/internal/common" 10 | "github.com/airbytehq/abctl/internal/paths" 11 | "github.com/airbytehq/abctl/internal/trace" 12 | "github.com/pterm/pterm" 13 | "sigs.k8s.io/kind/pkg/cluster" 14 | "sigs.k8s.io/kind/pkg/log" 15 | ) 16 | 17 | // Provider represents a k8s provider. 18 | type Provider struct { 19 | // Name of this provider 20 | Name string 21 | // ClusterName is the name of the cluster this provider will interact with 22 | ClusterName string 23 | // Context this provider should use 24 | Context string 25 | // Kubeconfig location 26 | Kubeconfig string 27 | } 28 | 29 | // Cluster returns a kubernetes cluster for this provider. 30 | func (p Provider) Cluster(ctx context.Context) (Cluster, error) { 31 | _, span := trace.NewSpan(ctx, "Provider.Cluster") 32 | defer span.End() 33 | 34 | if err := os.MkdirAll(filepath.Dir(p.Kubeconfig), 0o766); err != nil { 35 | return nil, fmt.Errorf("unable to create directory %s: %v", p.Kubeconfig, err) 36 | } 37 | 38 | kindProvider := cluster.NewProvider(cluster.ProviderWithLogger(&kindLogger{pterm: pterm.Debug})) 39 | if err := kindProvider.ExportKubeConfig(p.ClusterName, p.Kubeconfig, false); err != nil { 40 | pterm.Debug.Printfln("failed to export kube config: %s", err) 41 | } 42 | 43 | return &KindCluster{ 44 | p: kindProvider, 45 | kubeconfig: p.Kubeconfig, 46 | clusterName: p.ClusterName, 47 | }, nil 48 | } 49 | 50 | var ( 51 | _ log.Logger = (*kindLogger)(nil) 52 | _ log.InfoLogger = (*kindLogger)(nil) 53 | ) 54 | 55 | // kindLogger implements the k8s logger interfaces. 56 | // Necessarily in order to capture kind specify logging for debug purposes 57 | type kindLogger struct { 58 | pterm pterm.PrefixPrinter 59 | } 60 | 61 | func (k *kindLogger) Info(message string) { 62 | k.pterm.Println("kind - INFO: " + message) 63 | } 64 | 65 | func (k *kindLogger) Infof(format string, args ...interface{}) { 66 | k.pterm.Println(fmt.Sprintf("kind - INFO: "+format, args...)) 67 | } 68 | 69 | func (k *kindLogger) Enabled() bool { 70 | return true 71 | } 72 | 73 | func (k *kindLogger) Warn(message string) { 74 | k.pterm.Println("kind - WARN: " + message) 75 | } 76 | 77 | func (k *kindLogger) Warnf(format string, args ...interface{}) { 78 | k.pterm.Println(fmt.Sprintf("kind - WARN: "+format, args...)) 79 | } 80 | 81 | func (k *kindLogger) Error(message string) { 82 | k.pterm.Println("kind - ERROR: " + message) 83 | } 84 | 85 | func (k *kindLogger) Errorf(format string, args ...interface{}) { 86 | k.pterm.Println(fmt.Sprintf("kind - ERROR: "+format, args...)) 87 | } 88 | 89 | func (k *kindLogger) V(_ log.Level) log.InfoLogger { 90 | return k 91 | } 92 | 93 | const ( 94 | Kind = "kind" 95 | Test = "test" 96 | ) 97 | 98 | var ( 99 | // DefaultProvider represents the kind (https://kind.sigs.k8s.io/) provider. 100 | DefaultProvider = Provider{ 101 | Name: Kind, 102 | ClusterName: "airbyte-abctl", 103 | Context: common.AirbyteKubeContext, 104 | Kubeconfig: paths.Kubeconfig, 105 | } 106 | 107 | // TestProvider represents a test provider, for testing purposes 108 | TestProvider = Provider{ 109 | Name: Test, 110 | ClusterName: "test-airbyte-abctl", 111 | Context: "test-airbyte-abctl", 112 | Kubeconfig: filepath.Join(os.TempDir(), "abctl", paths.FileKubeconfig), 113 | } 114 | ) 115 | -------------------------------------------------------------------------------- /internal/maps/maps.go: -------------------------------------------------------------------------------- 1 | package maps 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "strings" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | // FromSlice converts a slice of dot-delimited string values into a map[string]any. 13 | // For example: 14 | // "a.b.c=123","a.b.d=124" would return { "a": { "b": { "c": 123, "d": 124 } } } 15 | func FromSlice(slice []string) map[string]any { 16 | m := map[string]any{} 17 | 18 | for _, s := range slice { 19 | // s has the format of: 20 | // a.b.c=xyz 21 | parts := strings.SplitN(s, "=", 2) 22 | // a.b.c becomes [a, b, c] 23 | keys := strings.Split(parts[0], ".") 24 | // xyz 25 | value := parts[1] 26 | 27 | // pointer to the root of the map, 28 | // as this map will contain nested maps, this pointer will be 29 | // updated to point to the root of the nested maps as it iterates 30 | // through the for loop 31 | p := m 32 | for i, k := range keys { 33 | // last key, put the value into the map 34 | if i == len(keys)-1 { 35 | // handle boolean values (convert "true"/"false" strings to Go bool types) 36 | // this is necessary for Helm chart dependency conditions 37 | // which require actual boolean values 38 | if value == "true" || value == "false" { 39 | boolValue, err := strconv.ParseBool(value) 40 | if err == nil { 41 | p[k] = boolValue 42 | continue 43 | } 44 | } 45 | 46 | p[k] = value 47 | continue 48 | } 49 | // if the nested map doesn't exist, create it 50 | if _, ok := p[k]; !ok { 51 | p[k] = map[string]any{} 52 | } 53 | // cast the key to a map[string]any 54 | // and update the pointer to point to it 55 | p = p[k].(map[string]any) 56 | } 57 | } 58 | 59 | return m 60 | } 61 | 62 | // FromYAMLFile converts a yaml file into a map[string]any. 63 | func FromYAMLFile(path string) (map[string]any, error) { 64 | if path == "" { 65 | return map[string]any{}, nil 66 | } 67 | 68 | raw, err := os.ReadFile(path) 69 | if err != nil { 70 | return nil, fmt.Errorf("failed to read file %s: %w", path, err) 71 | } 72 | var m map[string]any 73 | if err := yaml.Unmarshal(raw, &m); err != nil { 74 | return nil, fmt.Errorf("failed to unmarshal file %s: %w", path, err) 75 | } 76 | // ensure we don't return `nil, nil` 77 | if m == nil { 78 | return map[string]any{}, nil 79 | } 80 | return m, nil 81 | } 82 | 83 | // ToYAML converts the m map into a yaml string. 84 | // E.g. map[string]any{"a" : 1, "b", 2} becomes 85 | // a: 1 86 | // b: 2 87 | func ToYAML(m map[string]any) (string, error) { 88 | raw, err := yaml.Marshal(m) 89 | if err != nil { 90 | return "", fmt.Errorf("failed to marshal map: %w", err) 91 | } 92 | return string(raw), nil 93 | } 94 | 95 | // Merge merges the override map into the base map. 96 | // Modifying the base map in place. 97 | func Merge(base, override map[string]any) { 98 | for k, overrideVal := range override { 99 | if baseVal, ok := base[k]; ok { 100 | // both maps have this key 101 | baseChild, baseChildIsMap := baseVal.(map[string]any) 102 | overrideChild, overrideChildIsMap := overrideVal.(map[string]any) 103 | 104 | if baseChildIsMap && overrideChildIsMap { 105 | // both values are maps, recurse 106 | Merge(baseChild, overrideChild) 107 | } else { 108 | // override base with override 109 | base[k] = overrideVal 110 | } 111 | } else { 112 | // only override has this key 113 | base[k] = overrideVal 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /internal/ui/yaml_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestBubbleteaUI_ShowYAML(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | data any 14 | expectedOutput string 15 | expectedError string 16 | }{ 17 | { 18 | name: "simple struct", 19 | data: struct { 20 | Name string `yaml:"name"` 21 | Age int `yaml:"age"` 22 | }{ 23 | Name: "test", 24 | Age: 25, 25 | }, 26 | expectedOutput: `name: test 27 | age: 25 28 | `, 29 | }, 30 | { 31 | name: "nested struct", 32 | data: struct { 33 | User struct { 34 | ID string `yaml:"id"` 35 | Name string `yaml:"name"` 36 | } `yaml:"user"` 37 | Active bool `yaml:"active"` 38 | }{ 39 | User: struct { 40 | ID string `yaml:"id"` 41 | Name string `yaml:"name"` 42 | }{ 43 | ID: "123", 44 | Name: "test", 45 | }, 46 | Active: true, 47 | }, 48 | expectedOutput: `user: 49 | id: "123" 50 | name: test 51 | active: true 52 | `, 53 | }, 54 | { 55 | name: "slice of structs", 56 | data: []struct { 57 | ID int `yaml:"id"` 58 | Name string `yaml:"name"` 59 | }{ 60 | {ID: 1, Name: "first"}, 61 | {ID: 2, Name: "second"}, 62 | }, 63 | expectedOutput: `- id: 1 64 | name: first 65 | - id: 2 66 | name: second 67 | `, 68 | }, 69 | { 70 | name: "map data", 71 | data: map[string]any{ 72 | "key1": "value1", 73 | "key2": 42, 74 | "key3": true, 75 | }, 76 | expectedOutput: `key1: value1 77 | key2: 42 78 | key3: true 79 | `, 80 | }, 81 | { 82 | name: "nil data", 83 | data: nil, 84 | expectedOutput: "null\n", 85 | }, 86 | { 87 | name: "empty slice", 88 | data: []string{}, 89 | expectedOutput: "[]\n", 90 | }, 91 | { 92 | name: "string with special characters", 93 | data: struct { 94 | Path string `yaml:"path"` 95 | Command string `yaml:"command"` 96 | }{ 97 | Path: "/usr/local/bin", 98 | Command: "echo 'hello world'", 99 | }, 100 | expectedOutput: `path: /usr/local/bin 101 | command: echo 'hello world' 102 | `, 103 | }, 104 | { 105 | name: "multi-line string", 106 | data: struct { 107 | Description string `yaml:"description"` 108 | }{ 109 | Description: "This is a\nmulti-line\nstring", 110 | }, 111 | expectedOutput: `description: |- 112 | This is a 113 | multi-line 114 | string 115 | `, 116 | }, 117 | } 118 | 119 | for _, tt := range tests { 120 | t.Run(tt.name, func(t *testing.T) { 121 | stdout := &bytes.Buffer{} 122 | stderr := &bytes.Buffer{} 123 | ui := NewWithOptions(stdout, stderr, nil) 124 | 125 | err := ui.ShowYAML(tt.data) 126 | 127 | if tt.expectedError != "" { 128 | assert.Error(t, err) 129 | assert.Contains(t, err.Error(), tt.expectedError) 130 | } else { 131 | assert.NoError(t, err) 132 | // For maps with non-deterministic ordering, check line by line 133 | if _, isMap := tt.data.(map[string]any); isMap { 134 | actualLines := stdout.String() 135 | for key := range tt.data.(map[string]any) { 136 | assert.Contains(t, actualLines, key+":") 137 | } 138 | } else { 139 | assert.Equal(t, tt.expectedOutput, stdout.String()) 140 | } 141 | assert.Empty(t, stderr.String()) 142 | } 143 | }) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/airbytehq/abctl/internal/abctl" 12 | "github.com/airbytehq/abctl/internal/build" 13 | "github.com/airbytehq/abctl/internal/cmd" 14 | "github.com/airbytehq/abctl/internal/telemetry" 15 | "github.com/airbytehq/abctl/internal/trace" 16 | "github.com/airbytehq/abctl/internal/update" 17 | "github.com/alecthomas/kong" 18 | "github.com/pterm/pterm" 19 | ) 20 | 21 | func main() { 22 | os.Exit(run()) 23 | } 24 | 25 | // run is essentially the main method returning the exitCode of the program. 26 | // Run is separated to ensure that deferred functions are called (os.Exit prevents this). 27 | func run() int { 28 | // ensure the pterm info width matches the other printers 29 | pterm.Info.Prefix.Text = " INFO " 30 | 31 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 32 | defer stop() 33 | printUpdateMsg := checkForNewerAbctlVersion(ctx) 34 | 35 | telClient := telemetry.Get() 36 | 37 | shutdowns, err := trace.Init(ctx, telClient.User()) 38 | if err != nil { 39 | pterm.Debug.Printf("Trace disabled: %s", err) 40 | } 41 | defer func() { 42 | for _, shutdown := range shutdowns { 43 | shutdown() 44 | } 45 | }() 46 | 47 | runCmd := func(ctx context.Context) error { 48 | var root cmd.Cmd 49 | parser, err := kong.New( 50 | &root, 51 | kong.Name("abctl"), 52 | kong.Description("Airbyte's command line tool for managing a local Airbyte installation."), 53 | kong.UsageOnError(), 54 | kong.BindToProvider(bindCtx(ctx)), 55 | kong.BindTo(telClient, (*telemetry.Client)(nil)), 56 | ) 57 | if err != nil { 58 | return err 59 | } 60 | parsed, err := parser.Parse(os.Args[1:]) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | ctx, span := trace.NewSpan(ctx, fmt.Sprintf("abctl %s", parsed.Command())) 66 | defer span.End() 67 | 68 | parsed.BindToProvider(bindCtx(ctx)) 69 | return parsed.Run() 70 | } 71 | 72 | exitCode := handleErr(ctx, runCmd(ctx)) 73 | printUpdateMsg() 74 | return exitCode 75 | } 76 | 77 | func handleErr(ctx context.Context, err error) int { 78 | if err == nil { 79 | return 0 80 | } 81 | 82 | trace.CaptureError(ctx, err) 83 | 84 | pterm.Error.Println(err) 85 | 86 | var errParse *kong.ParseError 87 | if errors.As(err, &errParse) { 88 | _ = kong.DefaultHelpPrinter(kong.HelpOptions{}, errParse.Context) 89 | } 90 | 91 | var e *abctl.Error 92 | if errors.As(err, &e) { 93 | pterm.Println() 94 | pterm.Info.Println(e.Help()) 95 | } 96 | 97 | return 1 98 | } 99 | 100 | // checkForNewerAbctlVersion checks for a newer version of abctl. 101 | // Returns a function that, when called, will display a message if a newer version is available. 102 | func checkForNewerAbctlVersion(ctx context.Context) func() { 103 | c := make(chan string) 104 | go func() { 105 | defer close(c) 106 | ver, err := update.Check(ctx) 107 | if err != nil { 108 | pterm.Debug.Printfln("update check: %s", err) 109 | } else { 110 | c <- ver 111 | } 112 | }() 113 | 114 | return func() { 115 | ver := <-c 116 | if ver != "" { 117 | pterm.Info.Printfln("A new release of abctl is available: %s -> %s\nUpdating to the latest version is highly recommended", build.Version, ver) 118 | } 119 | } 120 | } 121 | 122 | // bindCtx exists to allow kong to correctly inject a context.Context into the Run methods on the commands. 123 | func bindCtx(ctx context.Context) func() (context.Context, error) { 124 | return func() (context.Context, error) { 125 | return ctx, nil 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /internal/helm/chart.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/airbytehq/abctl/internal/common" 8 | "github.com/airbytehq/abctl/internal/validate" 9 | goHelm "github.com/mittwald/go-helm-client" 10 | "golang.org/x/mod/semver" 11 | ) 12 | 13 | // ChartResolver handles resolution of Airbyte chart references from v1/v2 repositories, local paths, and URLs 14 | type ChartResolver struct { 15 | // v1RepoURL is the base URL for v1 Airbyte helm charts 16 | v1RepoURL string 17 | // v2RepoURL is the base URL for v2 Airbyte helm charts 18 | v2RepoURL string 19 | // client provides Chart.yaml metadata extraction for local paths and URLs 20 | client goHelm.Client 21 | } 22 | 23 | // NewChartResolver creates a resolver with default Airbyte v1/v2 repository URLs 24 | func NewChartResolver(client goHelm.Client) *ChartResolver { 25 | return &ChartResolver{ 26 | v1RepoURL: common.AirbyteRepoURLv1, 27 | v2RepoURL: common.AirbyteRepoURLv2, 28 | client: client, 29 | } 30 | } 31 | 32 | // NewChartResolverWithURLs creates a resolver with custom v1/v2 repository URLs 33 | func NewChartResolverWithURLs(client goHelm.Client, v1URL, v2URL string) *ChartResolver { 34 | return &ChartResolver{ 35 | v1RepoURL: v1URL, 36 | v2RepoURL: v2URL, 37 | client: client, 38 | } 39 | } 40 | 41 | // ChartIsV2Plus returns true if the chart version is v2.0.0 or higher 42 | func ChartIsV2Plus(v string) bool { 43 | return ChartEqualsOrHigherVersion(v, "v2.0.0") 44 | } 45 | 46 | // ChartIsV1Dot8Plus returns true if the chart version is v1.8.0 or higher 47 | func ChartIsV1Dot8Plus(v string) bool { 48 | return ChartEqualsOrHigherVersion(v, "v1.8.0") 49 | } 50 | 51 | // ChartEqualsOrHigherVersion returns true if the chart version is equals or higher than threshold 52 | func ChartEqualsOrHigherVersion(v string, threshold string) bool { 53 | if v == "" { 54 | return false 55 | } 56 | if v[0] != 'v' { 57 | v = "v" + v 58 | } 59 | return semver.Compare(v, threshold) >= 0 60 | } 61 | 62 | // ResolveChartReference resolves an Airbyte chart reference to its full URL/path and version. 63 | // For empty chart+version, returns latest v2 chart. 64 | // For version-only, uses v1/v2 repo based on base version (strips pre-release suffix for repo selection). 65 | // For URLs and local paths, returns as-is with version from chart metadata. 66 | func (r *ChartResolver) ResolveChartReference(chart, version string) (string, string, error) { 67 | if chart == "" { 68 | if version == "" { 69 | chartURL, chartVersion, err := GetLatestAirbyteChartUrlFromRepoIndex("", r.v2RepoURL) 70 | if err != nil { 71 | return "", "", err 72 | } 73 | return chartURL, chartVersion, nil 74 | } else { 75 | // Extract base version without suffix (e.g., "1.8.4-rc5" -> "1.8.4") 76 | // to determine which repo to use, but keep full version for URL 77 | baseVersion, _, _ := strings.Cut(version, "-") 78 | if ChartIsV2Plus(baseVersion) { 79 | // Construct the v2 chart URL. 80 | return fmt.Sprintf("%s/airbyte-%s.tgz", r.v2RepoURL, version), version, nil 81 | } else { 82 | // Construct the v1 chart URL. 83 | return fmt.Sprintf("%s/airbyte-%s.tgz", r.v1RepoURL, version), version, nil 84 | } 85 | } 86 | } 87 | 88 | // Since chart and version are mutually exclusive flags. Get the latest version for the chart. 89 | if validate.IsURL(chart) { 90 | meta, err := GetMetadataForURL(chart) 91 | if err != nil { 92 | return "", "", fmt.Errorf("failed to get helm chart metadata for url %s: %w", chart, err) 93 | } 94 | return chart, meta.Version, nil 95 | } 96 | 97 | meta, err := GetMetadataForRef(r.client, chart) 98 | if err != nil { 99 | return "", "", fmt.Errorf("failed to get helm chart metadata for reference %s: %w", chart, err) 100 | } 101 | 102 | return chart, meta.Version, nil 103 | } 104 | -------------------------------------------------------------------------------- /internal/cmd/local/credentials.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/airbytehq/abctl/internal/airbyte" 8 | "github.com/airbytehq/abctl/internal/k8s" 9 | "github.com/airbytehq/abctl/internal/service" 10 | "github.com/airbytehq/abctl/internal/telemetry" 11 | "github.com/pterm/pterm" 12 | "go.opencensus.io/trace" 13 | ) 14 | 15 | const ( 16 | airbyteAuthSecretName = "airbyte-auth-secrets" 17 | airbyteNamespace = "airbyte-abctl" 18 | 19 | secretPassword = "instance-admin-password" 20 | secretClientID = "instance-admin-client-id" 21 | secretClientSecret = "instance-admin-client-secret" 22 | ) 23 | 24 | type CredentialsCmd struct { 25 | Email string `help:"Specify a new email address to use for authentication."` 26 | Password string `help:"Specify a new password to use for authentication."` 27 | } 28 | 29 | func (cc *CredentialsCmd) Run(ctx context.Context, provider k8s.Provider, telClient telemetry.Client) error { 30 | ctx, span := trace.StartSpan(ctx, "local credentials") 31 | defer span.End() 32 | 33 | spinner := &pterm.DefaultSpinner 34 | 35 | return telClient.Wrap(ctx, telemetry.Credentials, func() error { 36 | k8sClient, err := service.DefaultK8s(provider.Kubeconfig, provider.Context) 37 | if err != nil { 38 | pterm.Error.Println("No existing cluster found") 39 | return nil 40 | } 41 | 42 | secret, err := k8sClient.SecretGet(ctx, airbyteNamespace, airbyteAuthSecretName) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | clientId := string(secret.Data[secretClientID]) 48 | clientSecret := string(secret.Data[secretClientSecret]) 49 | 50 | port, err := getPort(ctx, provider.ClusterName) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | abAPI := airbyte.New(fmt.Sprintf("http://localhost:%d", port), clientId, clientSecret) 56 | 57 | if cc.Email != "" { 58 | pterm.Info.Println("Updating email for authentication") 59 | if err := abAPI.SetOrgEmail(ctx, cc.Email); err != nil { 60 | pterm.Error.Println("Unable to update the email address") 61 | return fmt.Errorf("unable to udpate the email address: %w", err) 62 | } 63 | pterm.Success.Println("Email updated") 64 | } 65 | 66 | if cc.Password != "" && cc.Password != string(secret.Data[secretPassword]) { 67 | pterm.Info.Println("Updating password for authentication") 68 | secret.Data[secretPassword] = []byte(cc.Password) 69 | if err := k8sClient.SecretCreateOrUpdate(ctx, *secret); err != nil { 70 | pterm.Error.Println("Unable to update the password") 71 | return fmt.Errorf("unable to update the password: %w", err) 72 | } 73 | pterm.Success.Println("Password updated") 74 | 75 | // as the secret was updated, fetch it again 76 | secret, err = k8sClient.SecretGet(ctx, airbyteNamespace, airbyteAuthSecretName) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | spinner, _ = spinner.Start("Restarting airbyte-abctl-server") 82 | if err := k8sClient.DeploymentRestart(ctx, airbyteNamespace, "airbyte-abctl-server"); err != nil { 83 | pterm.Error.Println("Unable to restart airbyte-abctl-server") 84 | return fmt.Errorf("unable to restart airbyte-abctl-server: %w", err) 85 | } 86 | spinner.Success("Restarted airbyte-abctl-server") 87 | } 88 | 89 | orgEmail, err := abAPI.GetOrgEmail(ctx) 90 | if err != nil { 91 | pterm.Error.Println("Unable to determine organization email") 92 | return fmt.Errorf("unable to determine organization email: %w", err) 93 | } 94 | if orgEmail == "" { 95 | orgEmail = "[not set]" 96 | } 97 | 98 | pterm.Success.Println(fmt.Sprintf("Retrieving your credentials from '%s'", secret.Name)) 99 | pterm.Info.Println(fmt.Sprintf(`Credentials: 100 | Email: %s 101 | Password: %s 102 | Client-Id: %s 103 | Client-Secret: %s`, orgEmail, secret.Data[secretPassword], clientId, clientSecret)) 104 | return nil 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /internal/cmd/delete/dataplane_test.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "go.uber.org/mock/gomock" 10 | 11 | "github.com/airbytehq/abctl/internal/airbox" 12 | airboxmock "github.com/airbytehq/abctl/internal/airbox/mock" 13 | "github.com/airbytehq/abctl/internal/api" 14 | apimock "github.com/airbytehq/abctl/internal/api/mock" 15 | "github.com/airbytehq/abctl/internal/http" 16 | httpmock "github.com/airbytehq/abctl/internal/http/mock" 17 | uimock "github.com/airbytehq/abctl/internal/ui/mock" 18 | ) 19 | 20 | func TestDataplaneCmd_Run(t *testing.T) { 21 | tests := []struct { 22 | name string 23 | id string 24 | force bool 25 | expectedError string 26 | setupMocks func(ctrl *gomock.Controller) (airbox.ConfigStore, http.HTTPDoer, airbox.APIServiceFactory, *uimock.MockProvider) 27 | }{ 28 | { 29 | name: "success", 30 | id: "dp-123", 31 | force: true, 32 | setupMocks: func(ctrl *gomock.Controller) (airbox.ConfigStore, http.HTTPDoer, airbox.APIServiceFactory, *uimock.MockProvider) { 33 | mockCfg := airboxmock.NewMockConfigStore(ctrl) 34 | mockHTTP := httpmock.NewMockHTTPDoer(ctrl) 35 | mockService := apimock.NewMockService(ctrl) 36 | mockUI := uimock.NewMockProvider(ctrl) 37 | 38 | mockAPI := func(_ context.Context, _ http.HTTPDoer, _ airbox.ConfigStore) (api.Service, error) { 39 | return mockService, nil 40 | } 41 | 42 | gomock.InOrder( 43 | mockUI.EXPECT().Title("Deleting dataplane"), 44 | mockService.EXPECT().DeleteDataplane(gomock.Any(), "dp-123").Return(nil), 45 | mockUI.EXPECT().ShowSuccess("Dataplane ID 'dp-123' deleted successfully"), 46 | mockUI.EXPECT().NewLine(), 47 | ) 48 | 49 | return mockCfg, mockHTTP, mockAPI, mockUI 50 | }, 51 | }, 52 | { 53 | name: "factory error", 54 | id: "dp-123", 55 | force: true, 56 | expectedError: "mock api factory error", 57 | setupMocks: func(ctrl *gomock.Controller) (airbox.ConfigStore, http.HTTPDoer, airbox.APIServiceFactory, *uimock.MockProvider) { 58 | mockCfg := airboxmock.NewMockConfigStore(ctrl) 59 | mockHTTP := httpmock.NewMockHTTPDoer(ctrl) 60 | mockUI := uimock.NewMockProvider(ctrl) 61 | 62 | mockAPI := func(_ context.Context, _ http.HTTPDoer, _ airbox.ConfigStore) (api.Service, error) { 63 | return nil, errors.New("mock api factory error") 64 | } 65 | 66 | gomock.InOrder( 67 | mockUI.EXPECT().Title("Deleting dataplane"), 68 | ) 69 | 70 | return mockCfg, mockHTTP, mockAPI, mockUI 71 | }, 72 | }, 73 | { 74 | name: "delete error", 75 | id: "dp-123", 76 | force: true, 77 | expectedError: "failed to delete dataplane", 78 | setupMocks: func(ctrl *gomock.Controller) (airbox.ConfigStore, http.HTTPDoer, airbox.APIServiceFactory, *uimock.MockProvider) { 79 | mockCfg := airboxmock.NewMockConfigStore(ctrl) 80 | mockHTTP := httpmock.NewMockHTTPDoer(ctrl) 81 | mockService := apimock.NewMockService(ctrl) 82 | mockUI := uimock.NewMockProvider(ctrl) 83 | 84 | mockAPI := func(_ context.Context, _ http.HTTPDoer, _ airbox.ConfigStore) (api.Service, error) { 85 | return mockService, nil 86 | } 87 | 88 | gomock.InOrder( 89 | mockUI.EXPECT().Title("Deleting dataplane"), 90 | mockService.EXPECT().DeleteDataplane(gomock.Any(), "dp-123").Return(assert.AnError), 91 | ) 92 | 93 | return mockCfg, mockHTTP, mockAPI, mockUI 94 | }, 95 | }, 96 | } 97 | 98 | for _, tt := range tests { 99 | t.Run(tt.name, func(t *testing.T) { 100 | ctrl := gomock.NewController(t) 101 | defer ctrl.Finish() 102 | 103 | cfg, mockHTTP, mockAPI, mockUI := tt.setupMocks(ctrl) 104 | 105 | cmd := &DataplaneCmd{ 106 | ID: tt.id, 107 | Force: tt.force, 108 | } 109 | 110 | err := cmd.Run(context.Background(), cfg, mockHTTP, mockAPI, mockUI) 111 | 112 | if tt.expectedError != "" { 113 | assert.Error(t, err) 114 | assert.Contains(t, err.Error(), tt.expectedError) 115 | } else { 116 | assert.NoError(t, err) 117 | } 118 | }) 119 | } 120 | } -------------------------------------------------------------------------------- /internal/update/update_test.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/google/go-cmp/cmp" 13 | "github.com/google/go-cmp/cmp/cmpopts" 14 | ) 15 | 16 | func TestCheck(t *testing.T) { 17 | tests := []struct { 18 | name string 19 | remote string 20 | local string 21 | want string 22 | wantErr error 23 | }{ 24 | { 25 | name: "local version is newer", 26 | remote: remoteVersion("v0.1.0"), 27 | local: "v0.2.0", 28 | }, 29 | { 30 | name: "local version is older", 31 | remote: remoteVersion("v0.2.0"), 32 | local: "v0.1.0", 33 | want: "v0.2.0", 34 | }, 35 | { 36 | name: "local version is the same", 37 | remote: remoteVersion("v0.3.0"), 38 | local: "v0.3.0", 39 | want: "", 40 | }, 41 | { 42 | name: "no check if version is dev", 43 | local: "dev", 44 | want: "", 45 | wantErr: ErrDevVersion, 46 | }, 47 | } 48 | 49 | for _, tt := range tests { 50 | t.Run(tt.name, func(t *testing.T) { 51 | ctx := context.Background() 52 | h := mockDoer{ 53 | do: func(req *http.Request) (*http.Response, error) { 54 | return &http.Response{ 55 | StatusCode: http.StatusOK, 56 | Body: io.NopCloser(strings.NewReader(tt.remote)), 57 | }, nil 58 | }, 59 | } 60 | 61 | latest, err := check(ctx, h, tt.local) 62 | if d := cmp.Diff(tt.wantErr, err, cmpopts.EquateErrors()); d != "" { 63 | t.Errorf("unexpected error: %s", err) 64 | } 65 | if d := cmp.Diff(tt.want, latest); d != "" { 66 | t.Errorf("unexpected diff (-want, +got) = %s", d) 67 | } 68 | }) 69 | } 70 | } 71 | 72 | func TestCheck_HTTPRequest(t *testing.T) { 73 | var actualRequest *http.Request 74 | 75 | h := mockDoer{ 76 | do: func(req *http.Request) (*http.Response, error) { 77 | actualRequest = req 78 | return &http.Response{ 79 | StatusCode: http.StatusOK, 80 | Body: io.NopCloser(strings.NewReader(remoteVersion("v0.1.0"))), 81 | }, nil 82 | }, 83 | } 84 | 85 | if _, err := check(context.Background(), h, "v0.1.0"); err != nil { 86 | t.Error("unexpected error:", err) 87 | } 88 | // verify method 89 | if d := cmp.Diff(http.MethodGet, actualRequest.Method); d != "" { 90 | t.Errorf("unexpected method (-want, +got) = %s", d) 91 | } 92 | // verify url 93 | if d := cmp.Diff(url, actualRequest.URL.String()); d != "" { 94 | t.Errorf("unexpected url (-want, +got) = %s", d) 95 | } 96 | } 97 | 98 | func TestCheck_HTTPErr(t *testing.T) { 99 | tests := []struct { 100 | name string 101 | status int 102 | body string 103 | err error 104 | }{ 105 | { 106 | name: "404 status", 107 | status: http.StatusNotFound, 108 | }, 109 | { 110 | name: "418 status", 111 | status: http.StatusTeapot, 112 | }, 113 | { 114 | name: "500 status", 115 | status: http.StatusInternalServerError, 116 | }, 117 | { 118 | name: "invalid json", 119 | status: http.StatusOK, 120 | body: "", 121 | }, 122 | { 123 | name: "empty json", 124 | status: http.StatusOK, 125 | body: "{}", 126 | }, 127 | { 128 | name: "empty version", 129 | status: http.StatusOK, 130 | body: `{"tag_name":""}`, 131 | }, 132 | { 133 | name: "do returns error", 134 | status: http.StatusBadGateway, 135 | err: errors.New("test error"), 136 | }, 137 | } 138 | 139 | for _, tt := range tests { 140 | t.Run(tt.name, func(t *testing.T) { 141 | h := mockDoer{ 142 | do: func(req *http.Request) (*http.Response, error) { 143 | return &http.Response{ 144 | StatusCode: tt.status, 145 | Body: io.NopCloser(strings.NewReader(tt.body)), 146 | }, tt.err 147 | }, 148 | } 149 | 150 | _, err := check(context.Background(), h, "v0.1.0") 151 | if err == nil { 152 | t.Error("unexpected success") 153 | } 154 | }) 155 | } 156 | } 157 | 158 | func remoteVersion(version string) string { 159 | return fmt.Sprintf(`{ "tag_name": "%s" }`, version) 160 | } 161 | 162 | var _ doer = (*mockDoer)(nil) 163 | 164 | type mockDoer struct { 165 | do func(req *http.Request) (*http.Response, error) 166 | } 167 | 168 | func (m mockDoer) Do(req *http.Request) (*http.Response, error) { 169 | return m.do(req) 170 | } 171 | -------------------------------------------------------------------------------- /internal/api/regions.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | const ( 13 | regionsPath = "/v1/regions" 14 | ) 15 | 16 | // Region represents an Airbyte region 17 | type Region struct { 18 | ID string `json:"regionId"` 19 | Name string `json:"name"` 20 | CloudProvider string `json:"cloudProvider,omitempty"` 21 | Location string `json:"location,omitempty"` 22 | Status string `json:"status,omitempty"` 23 | } 24 | 25 | // RegionsResponse represents the response from listing regions 26 | type RegionsResponse struct { 27 | Regions []Region `json:"regions"` 28 | } 29 | 30 | // CreateRegionRequest represents the request to create a new region 31 | type CreateRegionRequest struct { 32 | Name string `json:"name"` 33 | OrganizationID string `json:"organizationId"` 34 | CloudProvider string `json:"cloudProvider,omitempty"` 35 | Location string `json:"location,omitempty"` 36 | } 37 | 38 | // CreateRegion creates a new region 39 | func (c *Client) CreateRegion(ctx context.Context, request CreateRegionRequest) (*Region, error) { 40 | jsonData, err := json.Marshal(request) 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to marshal request: %w", err) 43 | } 44 | 45 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, regionsPath, bytes.NewBuffer(jsonData)) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to create request: %w", err) 48 | } 49 | req.Header.Set("Content-Type", "application/json") 50 | 51 | resp, err := c.http.Do(req) 52 | if err != nil { 53 | return nil, fmt.Errorf("request failed: %w", err) 54 | } 55 | defer func() { _ = resp.Body.Close() }() // Connection cleanup, error doesn't affect functionality 56 | 57 | body, err := io.ReadAll(resp.Body) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to read response body: %w", err) 60 | } 61 | 62 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 63 | return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) 64 | } 65 | 66 | var region Region 67 | if err := json.Unmarshal(body, ®ion); err != nil { 68 | return nil, fmt.Errorf("failed to decode response: %w", err) 69 | } 70 | 71 | return ®ion, nil 72 | } 73 | 74 | // GetRegion retrieves a specific region by ID 75 | func (c *Client) GetRegion(ctx context.Context, regionID string) (*Region, error) { 76 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, regionsPath+"/"+regionID, nil) 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to create request: %w", err) 79 | } 80 | 81 | resp, err := c.http.Do(req) 82 | if err != nil { 83 | return nil, fmt.Errorf("request failed: %w", err) 84 | } 85 | defer func() { _ = resp.Body.Close() }() // Connection cleanup, error doesn't affect functionality 86 | 87 | if resp.StatusCode != http.StatusOK { 88 | body, _ := io.ReadAll(resp.Body) 89 | return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) 90 | } 91 | 92 | var region Region 93 | if err := json.NewDecoder(resp.Body).Decode(®ion); err != nil { 94 | return nil, fmt.Errorf("failed to decode response: %w", err) 95 | } 96 | 97 | return ®ion, nil 98 | } 99 | 100 | // ListRegions retrieves all regions for an organization 101 | func (c *Client) ListRegions(ctx context.Context, organizationID string) ([]*Region, error) { 102 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, regionsPath, nil) 103 | if err != nil { 104 | return nil, fmt.Errorf("failed to create request: %w", err) 105 | } 106 | 107 | if organizationID != "" { 108 | q := req.URL.Query() 109 | q.Set("organizationId", organizationID) 110 | req.URL.RawQuery = q.Encode() 111 | } 112 | 113 | resp, err := c.http.Do(req) 114 | if err != nil { 115 | return nil, fmt.Errorf("request failed: %w", err) 116 | } 117 | defer func() { _ = resp.Body.Close() }() // Connection cleanup, error doesn't affect functionality 118 | 119 | if resp.StatusCode != http.StatusOK { 120 | body, _ := io.ReadAll(resp.Body) 121 | return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) 122 | } 123 | 124 | var regions []*Region 125 | if err := json.NewDecoder(resp.Body).Decode(®ions); err != nil { 126 | return nil, fmt.Errorf("failed to decode response: %w", err) 127 | } 128 | 129 | return regions, nil 130 | } 131 | -------------------------------------------------------------------------------- /internal/auth/auth_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "go.uber.org/mock/gomock" 14 | 15 | httpmock "github.com/airbytehq/abctl/internal/http/mock" 16 | ) 17 | 18 | func TestCredentialsFromJSON(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | jsonData string 22 | expectErr error 23 | }{ 24 | { 25 | name: "valid json", 26 | jsonData: `{"access_token":"test-token","refresh_token":"test-refresh","token_type":"Bearer","expires_at":"2023-01-01T12:00:00Z"}`, 27 | }, 28 | { 29 | name: "invalid json", 30 | jsonData: `{"invalid": json}`, 31 | expectErr: errors.New("invalid character 'j' looking for beginning of value"), 32 | }, 33 | } 34 | 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | creds, err := credentialsFromJSON([]byte(tt.jsonData)) 38 | 39 | if tt.expectErr == nil { 40 | require.NoError(t, err) 41 | return 42 | } 43 | assert.Equal(t, tt.expectErr.Error(), err.Error()) 44 | assert.Nil(t, creds) 45 | }) 46 | } 47 | } 48 | 49 | func TestDiscoverProvider(t *testing.T) { 50 | ctrl := gomock.NewController(t) 51 | defer ctrl.Finish() 52 | 53 | mockHTTPClient := httpmock.NewMockHTTPDoer(ctrl) 54 | 55 | // Mock successful OIDC discovery response 56 | discoveryResponse := `{ 57 | "issuer": "https://example.com", 58 | "authorization_endpoint": "https://example.com/auth", 59 | "token_endpoint": "https://example.com/token" 60 | }` 61 | 62 | mockHTTPClient.EXPECT().Do(gomock.Any()).Return(&http.Response{ 63 | StatusCode: 200, 64 | Body: io.NopCloser(strings.NewReader(discoveryResponse)), 65 | }, nil) 66 | 67 | provider, err := DiscoverProvider(context.Background(), "https://example.com", mockHTTPClient) 68 | if err != nil { 69 | t.Fatalf("DiscoverProvider failed: %v", err) 70 | } 71 | if provider == nil { 72 | t.Fatal("expected provider, got nil") 73 | } 74 | } 75 | 76 | func TestDiscoverProviderWithClient(t *testing.T) { 77 | tests := []struct { 78 | name string 79 | setupHTTP func(mock *httpmock.MockHTTPDoer) 80 | expectErr error 81 | }{ 82 | { 83 | name: "success", 84 | setupHTTP: func(mock *httpmock.MockHTTPDoer) { 85 | response := `{ 86 | "issuer": "https://example.com", 87 | "authorization_endpoint": "https://example.com/auth", 88 | "token_endpoint": "https://example.com/token" 89 | }` 90 | mock.EXPECT().Do(gomock.Any()).Return(&http.Response{ 91 | StatusCode: 200, 92 | Body: io.NopCloser(strings.NewReader(response)), 93 | }, nil) 94 | }, 95 | }, 96 | { 97 | name: "http error", 98 | setupHTTP: func(mock *httpmock.MockHTTPDoer) { 99 | mock.EXPECT().Do(gomock.Any()).Return(nil, errors.New("network error")) 100 | }, 101 | expectErr: errors.New("failed to fetch provider configuration: network error"), 102 | }, 103 | { 104 | name: "non-200 status", 105 | setupHTTP: func(mock *httpmock.MockHTTPDoer) { 106 | mock.EXPECT().Do(gomock.Any()).Return(&http.Response{ 107 | StatusCode: 404, 108 | Body: io.NopCloser(strings.NewReader("Not Found")), 109 | }, nil) 110 | }, 111 | expectErr: errors.New("discovery failed with status 404"), 112 | }, 113 | { 114 | name: "invalid json response", 115 | setupHTTP: func(mock *httpmock.MockHTTPDoer) { 116 | mock.EXPECT().Do(gomock.Any()).Return(&http.Response{ 117 | StatusCode: 200, 118 | Body: io.NopCloser(strings.NewReader("invalid json")), 119 | }, nil) 120 | }, 121 | expectErr: errors.New("failed to decode provider configuration: invalid character 'i' looking for beginning of value"), 122 | }, 123 | } 124 | 125 | for _, tt := range tests { 126 | t.Run(tt.name, func(t *testing.T) { 127 | ctrl := gomock.NewController(t) 128 | defer ctrl.Finish() 129 | 130 | mockHTTPClient := httpmock.NewMockHTTPDoer(ctrl) 131 | tt.setupHTTP(mockHTTPClient) 132 | 133 | provider, err := DiscoverProvider(context.Background(), "https://example.com", mockHTTPClient) 134 | 135 | if tt.expectErr == nil { 136 | require.NoError(t, err) 137 | assert.NotNil(t, provider) 138 | return 139 | } 140 | assert.Equal(t, tt.expectErr.Error(), err.Error()) 141 | }) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /internal/abctl/error.go: -------------------------------------------------------------------------------- 1 | package abctl 2 | 3 | var _ error = (*Error)(nil) 4 | 5 | // Error adds a user-friendly help message to specific errors. 6 | type Error struct { 7 | help string 8 | msg string 9 | } 10 | 11 | // Help will displayed to the user if this specific error is ever returned. 12 | func (e *Error) Help() string { 13 | return e.help 14 | } 15 | 16 | // Error returns the error message. 17 | func (e *Error) Error() string { 18 | return e.msg 19 | } 20 | 21 | var ( 22 | // ErrAirbyteDir is returned anytime an there is an issue in accessing the paths.Airbyte directory. 23 | ErrAirbyteDir = &Error{ 24 | msg: "airbyte directory is inaccessible", 25 | help: `The ~/.airbyte directory is inaccessible. 26 | You may need to remove this directory before trying your command again.`, 27 | } 28 | 29 | // ErrClusterNotFound is returned in the event that no cluster was located. 30 | ErrClusterNotFound = &Error{ 31 | msg: "no existing cluster found", 32 | help: `No cluster was found. If this is unexpected, 33 | you may need to run the "local install" command again.`, 34 | } 35 | 36 | // ErrDocker is returned anytime an error occurs when attempting to communicate with docker. 37 | ErrDocker = &Error{ 38 | msg: "error communicating with docker", 39 | help: `An error occurred while communicating with the Docker daemon. 40 | Ensure that Docker is running and is accessible. You may need to upgrade to a newer version of Docker. 41 | For additional help please visit https://docs.docker.com/get-docker/`, 42 | } 43 | 44 | // ErrHelmStuck is returned if when running a helm install or upgrade command, a previous install or upgrade 45 | // attempt is already in progress which this tool cannot work around. 46 | ErrHelmStuck = &Error{ 47 | msg: "another helm operation (install/upgrade/rollback) is in progress", 48 | help: `An error occurred while attempting to run a helm install or upgrade. 49 | If this error persists, you may need to run the "abctl local uninstall" command before attempting to run the 50 | "abctl local install" command again. 51 | Your data will persist between the uninstall and install commands. 52 | `, 53 | } 54 | 55 | // ErrKubernetes is returned anytime an error occurs when attempting to communicate with the kubernetes cluster. 56 | ErrKubernetes = &Error{ 57 | msg: "error communicating with kubernetes", 58 | help: `An error occurred while communicating with the Kubernetes cluster. 59 | If this error persists, you may need to run the "abctl local uninstall" command before attempting to run the 60 | "abctl local install" command again. 61 | Your data will persist between the uninstall and install commands.`, 62 | } 63 | 64 | // ErrIngress is returned in the event that ingress configuration failed. 65 | ErrIngress = &Error{ 66 | msg: "error configuring ingress", 67 | help: `An error occurred while configuring ingress. 68 | This could be in indication that the ingress port is already in use by a different application. 69 | The ingress port can be changed by passing the flag --port.`, 70 | } 71 | 72 | // ErrPort is returned in the event that the requested port is unavailable. 73 | ErrPort = &Error{ 74 | msg: "error verifying port availability", 75 | help: `An error occurred while verifying if the request port is available. 76 | This could be in indication that the ingress port is already in use by a different application. 77 | The ingress port can be changed by passing the flag --port.`, 78 | } 79 | 80 | ErrIpAddressForHostFlag = &Error{ 81 | msg: "invalid host - can't use an IP address", 82 | help: `Looks like you provided an IP address to the --host flag. 83 | This won't work, because Kubernetes ingress rules require a lowercase domain name. 84 | 85 | By default, abctl will allow access from any hostname or IP, so you might not need the --host flag.`, 86 | } 87 | 88 | ErrInvalidHostFlag = &Error{ 89 | msg: "invalid host", 90 | help: `The --host flag expects a lowercase domain name, e.g. "example.com". 91 | IP addresses won't work. Ports won't work (e.g. example:8000). URLs won't work (e.g. http://example.com). 92 | 93 | By default, abctl will allow access from any hostname or IP, so you might not need the --host flag.`, 94 | } 95 | 96 | ErrBootloaderFailed = &Error{ 97 | msg: "bootloader failed", 98 | help: "The bootloader failed to its initialization checks or migrations. Try running again with --verbose to see the full bootloader logs.", 99 | } 100 | ) 101 | -------------------------------------------------------------------------------- /internal/cmd/config/init.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/airbytehq/abctl/internal/airbox" 10 | "github.com/airbytehq/abctl/internal/ui" 11 | ) 12 | 13 | // InitCmd represents the init command 14 | type InitCmd struct { 15 | Force bool `flag:"" help:"Overwrite existing airbox configuration."` 16 | } 17 | 18 | // Run executes the init command 19 | func (c *InitCmd) Run(ctx context.Context, cfgStore airbox.ConfigStore, ui ui.Provider) error { 20 | ui.Title("Initializing airbox configuration") 21 | 22 | // Check if config already exists first 23 | if cfgStore.Exists() && !c.Force { 24 | return fmt.Errorf("airbox configuration already exists, use --force to overwrite") 25 | } 26 | 27 | // Prompt for edition 28 | _, edition, err := ui.Select("What Airbyte control plane are you connecting to:", []string{"Enterprise Flex", "Self-Managed Enterprise"}) 29 | if err != nil { 30 | return fmt.Errorf("failed to get edition: %w", err) 31 | } 32 | 33 | var abCtx *airbox.Context 34 | var contextName string 35 | 36 | switch edition { 37 | case "Enterprise Flex": 38 | abCtx, contextName, err = c.setupCloud(ctx, ui) 39 | case "Self-Managed Enterprise": 40 | abCtx, contextName, err = c.setupEnterprise(ctx, ui) 41 | default: 42 | return fmt.Errorf("unsupported edition: %s", edition) 43 | } 44 | 45 | if err != nil { 46 | return err 47 | } 48 | 49 | // Create fresh config 50 | cfg := &airbox.Config{ 51 | Contexts: []airbox.NamedContext{}, 52 | } 53 | 54 | // Add the context 55 | cfg.AddContext(contextName, *abCtx) 56 | 57 | // Validate the entire config structure before saving 58 | if err := cfg.Validate(); err != nil { 59 | return fmt.Errorf("invalid configuration: %w", err) 60 | } 61 | 62 | // Save to local file 63 | if err := cfgStore.Save(cfg); err != nil { 64 | return fmt.Errorf("failed to save airbox config: %w", err) 65 | } 66 | 67 | ui.ShowSuccess("Configuration saved successfully!") 68 | ui.NewLine() 69 | 70 | return nil 71 | } 72 | 73 | // setupCloud configures for Cloud deployment with OAuth only 74 | func (c *InitCmd) setupCloud(ctx context.Context, ui ui.Provider) (*airbox.Context, string, error) { 75 | // Initialize the context that will hold our configuration 76 | abCtx := airbox.Context{ 77 | Edition: "cloud", 78 | } 79 | 80 | // Get cloud URLs with env var override support for testing/staging environments 81 | cloudDomain := os.Getenv("AIRBYTE_CLOUD_DOMAIN") 82 | if cloudDomain == "" { 83 | cloudDomain = "cloud.airbyte.com" 84 | } 85 | 86 | cloudAPIDomain := os.Getenv("AIRBYTE_CLOUD_API_DOMAIN") 87 | if cloudAPIDomain == "" { 88 | cloudAPIDomain = "api.airbyte.com" 89 | } 90 | 91 | // Set the base URLs in our context 92 | abCtx.AirbyteAPIURL = fmt.Sprintf("https://%s", cloudAPIDomain) 93 | abCtx.AirbyteURL = fmt.Sprintf("https://%s", cloudDomain) 94 | 95 | // OAuth only - load client credentials from environment 96 | envCfg, err := airbox.LoadOAuthEnvConfig() 97 | if err != nil { 98 | return nil, "", fmt.Errorf("failed to load OAuth config: %w", err) 99 | } 100 | abCtx.Auth = airbox.NewAuthWithOAuth2(envCfg.ClientID, envCfg.ClientSecret) 101 | 102 | // Use the airbyteURL as the context name 103 | return &abCtx, abCtx.AirbyteURL, nil 104 | } 105 | 106 | // setupEnterprise configures for Enterprise edition with OAuth 107 | func (c *InitCmd) setupEnterprise(ctx context.Context, ui ui.Provider) (*airbox.Context, string, error) { 108 | // Prompt for Airbyte URL 109 | airbyteURL, err := ui.TextInput("Enter your Airbyte instance URL (e.g., https://airbyte.yourcompany.com):", "", nil) 110 | if err != nil { 111 | return nil, "", fmt.Errorf("failed to get Airbyte URL: %w", err) 112 | } 113 | 114 | // Remove trailing slash if present 115 | airbyteURL = strings.TrimSuffix(airbyteURL, "/") 116 | 117 | // API host is base URL + /api 118 | apiHost := airbyteURL + "/api" 119 | 120 | // Initialize the context 121 | abCtx := airbox.Context{ 122 | AirbyteURL: airbyteURL, 123 | AirbyteAPIURL: apiHost, 124 | Edition: "enterprise", 125 | } 126 | 127 | // OAuth only - load client credentials from environment 128 | envCfg, err := airbox.LoadOAuthEnvConfig() 129 | if err != nil { 130 | return nil, "", fmt.Errorf("failed to load OAuth config: %w", err) 131 | } 132 | abCtx.Auth = airbox.NewAuthWithOAuth2(envCfg.ClientID, envCfg.ClientSecret) 133 | 134 | // Use the airbyteURL as the context name 135 | return &abCtx, airbyteURL, nil 136 | } 137 | -------------------------------------------------------------------------------- /internal/telemetry/client.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "sync" 11 | 12 | "github.com/pterm/pterm" 13 | ) 14 | 15 | type EventState string 16 | 17 | const ( 18 | Start EventState = "started" 19 | Failed = "failed" 20 | Success = "succeeded" 21 | ) 22 | 23 | type EventType string 24 | 25 | const ( 26 | Credentials EventType = "credentials" 27 | Deployments = "deployments" 28 | Install = "install" 29 | Migrate = "migrate" 30 | Status = "status" 31 | Uninstall = "uninstall" 32 | ) 33 | 34 | // Client interface for telemetry data. 35 | type Client interface { 36 | // Start should be called as quickly as possible. 37 | Start(context.Context, EventType) error 38 | // Success should be called only if the activity succeeded. 39 | Success(context.Context, EventType) error 40 | // Failure should be called only if the activity failed. 41 | Failure(context.Context, EventType, error) error 42 | // Attr should be called to add additional attributes to this activity. 43 | Attr(key, val string) 44 | // User returns the user identifier being used by this client 45 | User() string 46 | // Wrap wraps the func() error with the EventType, 47 | // calling the Start, Failure or Success methods correctly based on 48 | // the behavior of the func() error 49 | Wrap(context.Context, EventType, func() error) error 50 | } 51 | 52 | type getConfig struct { 53 | dnt bool 54 | userHome string 55 | h *http.Client 56 | } 57 | 58 | // GetOption is for optional configuration of the Get call. 59 | type GetOption func(*getConfig) 60 | 61 | // WithDNT tells the Get call to enable the do-not-track configuration. 62 | // If the DNT method returns true, there method is implicitly called. 63 | // This method exists to explicitly ensuring the client is running in dnt mode 64 | func WithDNT() GetOption { 65 | return func(gc *getConfig) { 66 | gc.dnt = true 67 | } 68 | } 69 | 70 | // WithUserHome tells the Get call which directory should be considered the user's home. 71 | // Primary for testing purposes. 72 | func WithUserHome(userHome string) GetOption { 73 | return func(gc *getConfig) { 74 | gc.userHome = userHome 75 | } 76 | } 77 | 78 | var ( 79 | // instance is the configured Client holder 80 | instance Client 81 | // lock is to ensure that Get is thread-safe 82 | lock sync.Mutex 83 | ) 84 | 85 | // Get returns the already configured telemetry Client or configures a new one returning it. 86 | // If a previously configured Client exists, that one will be returned. 87 | func Get(opts ...GetOption) Client { 88 | lock.Lock() 89 | defer lock.Unlock() 90 | 91 | if instance != nil { 92 | return instance 93 | } 94 | 95 | var getCfg getConfig 96 | for _, opt := range opts { 97 | opt(&getCfg) 98 | } 99 | 100 | if getCfg.dnt || DNT() { 101 | instance = NoopClient{} 102 | return instance 103 | } 104 | 105 | if getCfg.userHome == "" { 106 | getCfg.userHome, _ = os.UserHomeDir() 107 | } 108 | 109 | getOrCreateConfigFile := func(getCfg getConfig) (Config, error) { 110 | configPath := filepath.Join(getCfg.userHome, ConfigFile) 111 | 112 | // if no file exists, create a new one 113 | analyticsCfg, err := loadConfigFromFile(configPath) 114 | if errors.Is(err, os.ErrNotExist) { 115 | // file not found, create a new one 116 | analyticsCfg = Config{AnalyticsID: NewUUID()} 117 | if err := writeConfigToFile(configPath, analyticsCfg); err != nil { 118 | return analyticsCfg, fmt.Errorf("unable to write file to %s: %w", configPath, err) 119 | } 120 | pterm.Info.Println(Welcome) 121 | } else if err != nil { 122 | return Config{}, fmt.Errorf("unable to load config from %s: %w", configPath, err) 123 | } 124 | 125 | // if a file exists but doesn't have a uuid, create a new uuid 126 | if analyticsCfg.AnalyticsID.IsZero() { 127 | analyticsCfg.AnalyticsID = NewUUID() 128 | if err := writeConfigToFile(configPath, analyticsCfg); err != nil { 129 | return analyticsCfg, fmt.Errorf("unable to write file to %s: %w", configPath, err) 130 | } 131 | } 132 | 133 | return analyticsCfg, nil 134 | } 135 | 136 | cfg, err := getOrCreateConfigFile(getCfg) 137 | if err != nil { 138 | pterm.Warning.Printfln("unable to create telemetry config file: %s", err.Error()) 139 | instance = NoopClient{} 140 | } else { 141 | instance = NewSegmentClient(cfg) 142 | } 143 | 144 | return instance 145 | } 146 | -------------------------------------------------------------------------------- /internal/k8s/ingress.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | 7 | "github.com/airbytehq/abctl/internal/common" 8 | "github.com/airbytehq/abctl/internal/helm" 9 | networkingv1 "k8s.io/api/networking/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | // Ingress creates an ingress type for defining the webapp ingress rules. 14 | func Ingress(chartVersion string, hosts []string) *networkingv1.Ingress { 15 | var ingressClassName = "nginx" 16 | 17 | // if no host is defined, default to an empty host 18 | if len(hosts) == 0 { 19 | hosts = append(hosts, "") 20 | } else { 21 | // If a host that isn't `localhost` was provided, create a second rule for localhost. 22 | // This is required to ensure we can talk to Airbyte via localhost 23 | if !slices.Contains(hosts, "localhost") { 24 | hosts = append(hosts, "localhost") 25 | } 26 | // If a host that isn't `host.docker.internal` was provided, create a second rule for localhost. 27 | // This is required to ensure we can talk to other containers. 28 | if !slices.Contains(hosts, "host.docker.internal") { 29 | hosts = append(hosts, "host.docker.internal") 30 | } 31 | } 32 | 33 | var rules []networkingv1.IngressRule 34 | for _, host := range hosts { 35 | rules = append(rules, ingressRules(chartVersion, host)) 36 | } 37 | 38 | return &networkingv1.Ingress{ 39 | TypeMeta: metav1.TypeMeta{}, 40 | ObjectMeta: metav1.ObjectMeta{ 41 | Name: common.AirbyteIngress, 42 | Namespace: common.AirbyteNamespace, 43 | }, 44 | Spec: networkingv1.IngressSpec{ 45 | IngressClassName: &ingressClassName, 46 | Rules: rules, 47 | }, 48 | } 49 | } 50 | 51 | // ingressRule creates a rule for the host with proper API routing. 52 | func ingressRules(chartVersion string, host string) networkingv1.IngressRule { 53 | rules := ingressRulesForV1() 54 | if helm.ChartIsV1Dot8Plus(chartVersion) { 55 | rules = ingressRulesForV1Dot8Plus() 56 | } 57 | 58 | return networkingv1.IngressRule{ 59 | Host: host, 60 | IngressRuleValue: rules, 61 | } 62 | } 63 | 64 | func ingressRulesForV1() networkingv1.IngressRuleValue { 65 | var pathType = networkingv1.PathType("Prefix") 66 | 67 | return networkingv1.IngressRuleValue{ 68 | HTTP: &networkingv1.HTTPIngressRuleValue{ 69 | Paths: []networkingv1.HTTPIngressPath{ 70 | // Route connector builder API to connector-builder-server 71 | { 72 | Path: "/api/v1/connector_builder", 73 | PathType: &pathType, 74 | Backend: networkingv1.IngressBackend{ 75 | Service: &networkingv1.IngressServiceBackend{ 76 | Name: fmt.Sprintf("%s-airbyte-connector-builder-server-svc", common.AirbyteChartRelease), 77 | Port: networkingv1.ServiceBackendPort{ 78 | Name: "http", 79 | }, 80 | }, 81 | }, 82 | }, 83 | // Default route for everything else to webapp 84 | { 85 | Path: "/", 86 | PathType: &pathType, 87 | Backend: networkingv1.IngressBackend{ 88 | Service: &networkingv1.IngressServiceBackend{ 89 | Name: fmt.Sprintf("%s-airbyte-webapp-svc", common.AirbyteChartRelease), 90 | Port: networkingv1.ServiceBackendPort{ 91 | Name: "http", 92 | }, 93 | }, 94 | }, 95 | }, 96 | }, 97 | }, 98 | } 99 | } 100 | 101 | func ingressRulesForV1Dot8Plus() networkingv1.IngressRuleValue { 102 | var pathType = networkingv1.PathType("Prefix") 103 | 104 | return networkingv1.IngressRuleValue{ 105 | HTTP: &networkingv1.HTTPIngressRuleValue{ 106 | Paths: []networkingv1.HTTPIngressPath{ 107 | // Route connector builder API to connector-builder-server 108 | { 109 | Path: "/api/v1/connector_builder", 110 | PathType: &pathType, 111 | Backend: networkingv1.IngressBackend{ 112 | Service: &networkingv1.IngressServiceBackend{ 113 | Name: fmt.Sprintf("%s-airbyte-connector-builder-server-svc", common.AirbyteChartRelease), 114 | Port: networkingv1.ServiceBackendPort{ 115 | Name: "http", 116 | }, 117 | }, 118 | }, 119 | }, 120 | // Default route for everything else to the server 121 | { 122 | Path: "/", 123 | PathType: &pathType, 124 | Backend: networkingv1.IngressBackend{ 125 | Service: &networkingv1.IngressServiceBackend{ 126 | Name: fmt.Sprintf("%s-airbyte-server-svc", common.AirbyteChartRelease), 127 | Port: networkingv1.ServiceBackendPort{ 128 | Name: "http", 129 | }, 130 | }, 131 | }, 132 | }, 133 | }, 134 | }, 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /internal/auth/mocks_creds_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/auth/auth.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen --source internal/auth/auth.go -destination internal/auth/mocks_creds_test.go -package auth 7 | // 8 | 9 | // Package auth is a generated GoMock package. 10 | package auth 11 | 12 | import ( 13 | http "net/http" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockCredentialsStore is a mock of CredentialsStore interface. 20 | type MockCredentialsStore struct { 21 | ctrl *gomock.Controller 22 | recorder *MockCredentialsStoreMockRecorder 23 | isgomock struct{} 24 | } 25 | 26 | // MockCredentialsStoreMockRecorder is the mock recorder for MockCredentialsStore. 27 | type MockCredentialsStoreMockRecorder struct { 28 | mock *MockCredentialsStore 29 | } 30 | 31 | // NewMockCredentialsStore creates a new mock instance. 32 | func NewMockCredentialsStore(ctrl *gomock.Controller) *MockCredentialsStore { 33 | mock := &MockCredentialsStore{ctrl: ctrl} 34 | mock.recorder = &MockCredentialsStoreMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockCredentialsStore) EXPECT() *MockCredentialsStoreMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // Load mocks base method. 44 | func (m *MockCredentialsStore) Load() (*Credentials, error) { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "Load") 47 | ret0, _ := ret[0].(*Credentials) 48 | ret1, _ := ret[1].(error) 49 | return ret0, ret1 50 | } 51 | 52 | // Load indicates an expected call of Load. 53 | func (mr *MockCredentialsStoreMockRecorder) Load() *gomock.Call { 54 | mr.mock.ctrl.T.Helper() 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockCredentialsStore)(nil).Load)) 56 | } 57 | 58 | // Save mocks base method. 59 | func (m *MockCredentialsStore) Save(arg0 *Credentials) error { 60 | m.ctrl.T.Helper() 61 | ret := m.ctrl.Call(m, "Save", arg0) 62 | ret0, _ := ret[0].(error) 63 | return ret0 64 | } 65 | 66 | // Save indicates an expected call of Save. 67 | func (mr *MockCredentialsStoreMockRecorder) Save(arg0 any) *gomock.Call { 68 | mr.mock.ctrl.T.Helper() 69 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockCredentialsStore)(nil).Save), arg0) 70 | } 71 | 72 | // MockProvider is a mock of Provider interface. 73 | type MockProvider struct { 74 | ctrl *gomock.Controller 75 | recorder *MockProviderMockRecorder 76 | isgomock struct{} 77 | } 78 | 79 | // MockProviderMockRecorder is the mock recorder for MockProvider. 80 | type MockProviderMockRecorder struct { 81 | mock *MockProvider 82 | } 83 | 84 | // NewMockProvider creates a new mock instance. 85 | func NewMockProvider(ctrl *gomock.Controller) *MockProvider { 86 | mock := &MockProvider{ctrl: ctrl} 87 | mock.recorder = &MockProviderMockRecorder{mock} 88 | return mock 89 | } 90 | 91 | // EXPECT returns an object that allows the caller to indicate expected use. 92 | func (m *MockProvider) EXPECT() *MockProviderMockRecorder { 93 | return m.recorder 94 | } 95 | 96 | // Do mocks base method. 97 | func (m *MockProvider) Do(req *http.Request) (*http.Response, error) { 98 | m.ctrl.T.Helper() 99 | ret := m.ctrl.Call(m, "Do", req) 100 | ret0, _ := ret[0].(*http.Response) 101 | ret1, _ := ret[1].(error) 102 | return ret0, ret1 103 | } 104 | 105 | // Do indicates an expected call of Do. 106 | func (mr *MockProviderMockRecorder) Do(req any) *gomock.Call { 107 | mr.mock.ctrl.T.Helper() 108 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Do", reflect.TypeOf((*MockProvider)(nil).Do), req) 109 | } 110 | 111 | // Load mocks base method. 112 | func (m *MockProvider) Load() (*Credentials, error) { 113 | m.ctrl.T.Helper() 114 | ret := m.ctrl.Call(m, "Load") 115 | ret0, _ := ret[0].(*Credentials) 116 | ret1, _ := ret[1].(error) 117 | return ret0, ret1 118 | } 119 | 120 | // Load indicates an expected call of Load. 121 | func (mr *MockProviderMockRecorder) Load() *gomock.Call { 122 | mr.mock.ctrl.T.Helper() 123 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockProvider)(nil).Load)) 124 | } 125 | 126 | // Save mocks base method. 127 | func (m *MockProvider) Save(arg0 *Credentials) error { 128 | m.ctrl.T.Helper() 129 | ret := m.ctrl.Call(m, "Save", arg0) 130 | ret0, _ := ret[0].(error) 131 | return ret0 132 | } 133 | 134 | // Save indicates an expected call of Save. 135 | func (mr *MockProviderMockRecorder) Save(arg0 any) *gomock.Call { 136 | mr.mock.ctrl.T.Helper() 137 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockProvider)(nil).Save), arg0) 138 | } 139 | --------------------------------------------------------------------------------