Hacking on Kargo
Kargo is implemented in Go. For maximum productivity in your text editor or IDE, it is recommended that you have installed the latest stable releases of Go and applicable editor/IDE extensions, however, this is not strictly required to be successful.
Running Tests
In order to minimize the setup required to apply small changes and to reduce the incidence of tests passing locally, but failing during the continuous integration process due to environmental differences, we've made it easy to execute tests within a container that is maximally similar to those used in CI.
To take advantage of this, you only need make and
Docker (or a Docker-compatible
container-runtime).
To run all unit tests:
make hack-test-unit
If you wish to opt-out of executing the tests within a container (for
performance reasons, perhaps), drop the hack- prefix from the target:
make test-unit
This will require Go to be installed locally.
Running Linters
It is also possible to execute a variety of different linters that perform static code analysis, detect code hygiene issues, assert adherence to project standards, etc. As with unit tests, we've made it easy to execute linters within a container that is maximally similar to those used in CI.
To lint Go code only:
make hack-lint-go
To lint generated protobuf definitions only:
make hack-lint-proto
To lint Helm charts only:
make hack-lint-charts
To run all linters with one command:
make hack-lint
If you wish to opt-out of executing any or all linters within a container (for
performance reasons, perhaps), drop the hack- prefix from the desired target.
This will require quite a variety of tools to be installed locally, so we do not recommend this if you can avoid it.
Executing Code Generation
Anytime the contents of the api/ directory have been modified, a code
generation process must be manually executed. As with tests and linters, this
process is easy to execute within a container, which eliminates the need to
install various tools or specific versions thereof:
make hack-codegen
If you wish to opt-out of executing code-generation within a container (for
performance reasons, perhaps), drop the hack- prefix from the target:
make codegen
This will require quite a variety of tools to be installed locally, so we do not recommend this if you can avoid it.
Building the Image
To build source into a Docker image that will be tagged as kargo:dev,
execute the following:
make hack-build
There is seldom a need to do this, as the next section will cover a better option for rapidly building and deploying Kargo from source.
The Docker buildx machine required by the
build process has to be created with the --driver-opt network=host option to
allow it to access the (temporary) local image registry used for the base image.
If you encounter an error during the build process (e.g. failed to resolve source metadata for localhost:5001/kargo-base:latest-arm64 or granting entitlement network.host is not allowed by build daemon configuration), you
may need to (re)create the machine using docker buildx create with this
option set.
Iterating Quickly
This section focuses on the best approaches for gaining rapid feedback on changes you make to Kargo's code base.
The fastest path to learning whether changes you have applied work as desired is to execute unit tests as described in previous sections. If the changes you are applying are complex, it can also be advantageous to exercise them, end-to-end, as a user would. Because Kargo is dependent on a Kubernetes cluster, this raises the question of how Kargo can not only be built from source, but also deployed to a live Kubernetes cluster efficiently enough to enable a tight feedback loop as you continue iterating on your changes.
The remainder of this section covers the approaches we recommend for enabling this.
-
Launch or re-use an existing local Kubernetes cluster.
Any of the following options are viable:
- Docker Desktop
- OrbStack
- kind
- k3d
If you are a Docker Desktop user, you can follow these instructions to enable its built-in Kubernetes support. If it's already enabled, you're ready to go.
infoA specific benefit of this option is that nothing special is required in terms of creating a local image registry connected to the cluster. Additionally, this approach requires no specific port-forwarding rules to be defined.
infoAlthough this is one of the fastest paths to a local Kubernetes cluster, be aware that Docker Desktop supports only a single Kubernetes cluster. If that cluster reaches a state you are dissatisfied with, resetting it will remove not just Kargo-related resources, but all your workloads and data.
OrbStack is a fast, lightweight, drop-in replacement for Docker Desktop for Mac OS only. You can follow these instructions to enable its built-in Kubernetes support. If it's already enabled, you're ready to go.
infoA specific benefit of this option is that nothing special is required in terms of creating a local image registry connected to the cluster. Additionally, this approach requires no specific port-forwarding rules to be defined.
infoAlthough this is one of the fastest paths to a local Kubernetes cluster, be aware that OrbStack supports only a single Kubernetes cluster. If that cluster reaches a state you are dissatisfied with, resetting it will remove not just Kargo-related resources, but all your workloads and data.
If you have any Docker-compatible container runtime installed (including native Docker, Docker Desktop, or OrbStack), you can easily launch a disposable cluster to facilitate Kargo development using kind. You do not need to install it in advance.
The following
maketarget will launch a kind cluster with a local image registry wired into it, various port-forwarding rules pre-configured, and Kargo's prerequisites installed:make hack-kind-upinfoThe
hack-kind-uptarget will ensure suitable versions ofkindand ctlptl (used for declarative kind configuration) are available. Both are pinned asgo tooldependencies in the rootgo.modand built on demand -- no manual installation is needed.infoWhile this option is a bit more complex than using Docker Desktop or OrbStack directly, it offers the advantage of being fully-disposable. If your cluster reaches a state you are dissatisfied with, you can simply destroy it and launch a new one.
If you have any Docker-compatible container runtime installed (including native Docker, Docker Desktop, or OrbStack), you can easily launch a disposable cluster to facilitate Kargo development using k3d. You do not need to install it in advance.
The following
maketarget will launch a kind cluster with a local image registry wired into it, various port-forwarding rules pre-configured, and Kargo's prerequisites installed:make hack-k3d-upinfoThe
hack-k3d-uptarget will ensure suitable versions ofk3dand ctlptl (used for declarative k3d configuration) are available.k3dis installed intohack/bin/, whilectlptlis pinned as ago tooldependency in the rootgo.modand built on demand.infoWhile this option is a bit more complex than using Docker Desktop or OrbStack directly, it offers the advantage of being fully-disposable. If your cluster reaches a state you are dissatisfied with, you can simply destroy it and launch a new one.
-
Optional: Configure and start a tunnel for the external webhooks server:
If you are working on or testing the external webhooks server, you will benefit from configuring a tunnel from the outside world so that traffic originating from platforms like GitHub, Docker Hub, and others can reach the server. It is easy to accomplish this using ngrok. If you wish to do so, you must first install ngrok yourself.
With
ngrokinstalled, you can conveniently open a tunnel tolocalhost:30083(where the next step will run the external webhooks server) using:make hack-ngrokAllow this process to run while you are working on or testing the external webhooks server. Interrupt it with
ctrl + cwhen you are done.If you have a paid ngrok account that allows you to use a custom domain name for your tunnels, you can specify that domain name using the
KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAMEand (to properly set the protocol scheme)KARGO_EXTERNAL_WEBHOOKS_SERVER_TLS_TERMINATED_UPSTREAMenvironment variables before running the make target:export KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAME=my-tunnel.ngrok.ioexport KARGO_EXTERNAL_WEBHOOKS_SERVER_TLS_TERMINATED_UPSTREAM=truemake hack-ngrokIf the
KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAMEenvironment variable is undefined, the tunnel will utilize a dynamically-generated subdomain ofngrok.io. -
Build and deploy Kargo from source:
Tilt is a convenient tool that builds container images from source and seamlessly deploys them to a local Kubernetes cluster. More importantly, it enables developers to rapidly rebuild and replace running components with the click of a button. You do not need to install it in advance.
make hack-tilt-upinfoThe
hack-tilt-uptarget will ensure the installation of a suitable versions oftiltandhelmintohack/bin/.When run for the first time on a new development cluster,
make hack-tilt-upwill install suitable versions of cert-manager, Argo CD, and Argo Rollouts into your cluster usinghelm.The Argo CD dashboard will be exposed at localhost:30080.
The username and password are both
admin.You may safely ignore any certificate warnings.
Tilt will launch a web-based UI running at http://localhost:10350. Visit this in your web browser to view the build and deployment status of each Kargo component as well as the logs from each component.
infoTilt is often configured to watch files and automatically rebuild and replace running components when their source code is changed. This is deliberately disabled for Kargo since the Docker image takes long enough to build that it’s better to conserve system resources by only rebuilding when you choose. The web UI makes it easy to identify components whose source has been altered. They can be rebuilt and replaced with a single click.
infoIf you specified a custom domain name for a tunnel to the external webhooks server in the previous step by defining a value for the
KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAMEenvironment variable, you should export the same value for that environment variable before runningmake tilt upas well:export KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAME=my-tunnel.ngrok.iomake hack-tilt-up -
If necessary, build the CLI from source:
make hack-build-cliThis will produce an executable at
bin/kargo-<os>-<arch>.You can log in using:
bin/kargo-<os>-<arch> login http://localhost:30081 \--admin \--password admin \--insecure-skip-tls-verify -
If necessary, access the Kargo UI at localhost:30082.
The admin account password is
admin.You may safely ignore any certificate warnings.
-
When you are done with Tilt, interrupt the running
make hack-tilt-upprocess withctrl + c. Components will remain running in the cluster, but Tilt will no longer be in control. If Tilt is restarted later, it will retake control of the already-running components.If you wish to undeploy everything Tilt has deployed for you (except for prerequisites), use
make hack-tilt-down. -
Clean up your local Kubernetes cluster.
- Docker Desktop
- OrbStack
- kind
- k3d
Docker Desktop supports only a single Kubernetes cluster. If you are comfortable deleting not just just Kargo-related resources, but all your workloads and data, the cluster can be reset from the Docker Desktop Dashboard.
If, instead, you wish to preserve non-Kargo-related workloads and data, you will need to manually uninstall Kargo's prerequisites:
make hack-uninstall-prereqsOrbStack supports only a single Kubernetes cluster. If you are comfortable deleting not just just Kargo-related resources, but all your workloads and data, you can destroy the cluster with:
orb delete k8sIf, instead, you wish to preserve non-Kargo-related workloads and data, you will need to manually uninstall Kargo's prerequisites:
make hack-uninstall-prereqsTo destroy the cluster, use:
make hack-kind-downinfoThis command deliberately leaves your local image registry running so that if you resume work later, you are doing so with a local registry that’s already primed with most layers of Kargo’s image.
If you wish to stop the registry, use:
docker stop kargo-dev-registryTo destroy it, use:
docker rm -f kargo-dev-registryTo destroy the cluster, use:
make hack-k3d-downinfoThis command deliberately leaves your local image registry running so that if you resume work later, you are doing so with a local registry that’s already primed with most layers of Kargo’s image.
If you wish to stop the registry, use:
docker stop kargo-dev-registryTo destroy it, use:
docker rm -f kargo-dev-registry
Working with PostgreSQL
Tilt runs the PostgreSQL bundled with the Helm chart, configured by the
database.postgres values in hack/tilt/values.dev.yaml, and forwards
127.0.0.1:15432 to its port 5432. The database, username, and password are all kargo. Inside the
cluster, the address is kargo-postgres.kargo.svc:5432.
Two kinds of data live in it. The management controller mirrors Projects,
Stages, and Promotions from Kubernetes, which remains their source of truth.
Project and Stage rows record identities and follow their objects, including
out of existence. A Stage row also records the selectors by which the Stage
governs Targets (NULL for a classic Stage), and carries the number last given
to one of its PromotionRollouts, which the mirror never writes. Promotion rows record what each Promotion did, its Stage,
Freight, Target if any, phase, and timing, along with the Freight of the
whole collection it rendered, one row per origin (promotion_freight), and
are never deleted: Kubernetes retains only a
bounded number of recent Promotions per Stage, and the database keeps them
all as a history. A Promotion deleted before it finished is recorded as
Aborted as of its deletion, so no row claims a Promotion is running that
no longer exists. Targets, on the other hand, exist only in the database: the API server serves them
through /v1beta1/projects/{project}/targets, and Target is no longer a
custom resource at all. The chart installs no CRD for it, kubectl does not
see Targets, and the Go type keeps the shape of a Kubernetes resource without
being one (no apiVersion or kind, no scheme registration), so that
clients and the UI can treat it like one. Target custom resources from
earlier versions are not migrated; a chart upgrade removes the CRD and them
with it. A Target names its Project rather than referencing the mirrored
row, so writing one never waits for the mirror. Deleting a Project does not
yet remove its Targets.
Only control plane components open the database. The controller may run far
from the control plane, on infrastructure where the database is not
reachable, so it resolves the Targets a Stage governs and the Target a
Promotion promotes to by calling the API server at API_SERVER_ADDRESS. It
acts as the ServiceAccount of the control plane that its Kubernetes
credential for the control plane resolves to. At startup it asks the control
plane who it is, with a SelfSubjectReview, and refuses to start if the answer
is not a ServiceAccount; from then on it requests short-lived tokens for that
ServiceAccount through the Kubernetes TokenRequest API and sends them to the
API server, which verifies each with a TokenReview and authorizes each
request against the ServiceAccount's RBAC, which grants list and get on
targets. client-go authenticates the identity check and the token
requests however the credential says, so the projected ServiceAccount token
of an in-cluster controller, a token in a shard's kubeconfig, a client
certificate or an exec plugin all serve, and nothing configures the
ServiceAccount. The ServiceAccount must be allowed to create its own
token subresource: the chart grants that to the in-cluster controller, and
a shard's operator grants it to the ServiceAccount of the control plane they
issued the shard a credential for, bound to the kargo-controller
ClusterRole. Each token lives for API_SERVER_TOKEN_LIFETIME (an hour by
default) and is replaced once most of that has passed.
The chart points a controller installed beside the API server at the API
server's Service and, when it generated the API server's certificate, hands
the controller its CA (API_SERVER_CA_CERT_PATH). A controller installed
elsewhere, such as a shard in another cluster, is given the address at which
it reaches the API server through controller.apiServer.address. Each
request is bounded by API_SERVER_TIMEOUT (30 seconds by default), so an
API server that accepts a connection and then hangs cannot hold a reconcile
open indefinitely.
Every component that uses the database finds it through the same settings
(DATABASE_URL, or DATABASE_HOST and its companions), which Tilt sets from
the chart. A component without them runs with the database features off: the
API server answers Target requests with 501 Not Implemented. A component
with them waits, for up to DATABASE_CONNECT_TIMEOUT, for the database to
accept connections and for the schema to reach the version it expects, since
the migrations usually run alongside it; it refuses to serve against an older
schema. A controller without API_SERVER_ADDRESS (the
chart leaves it unset when the API server is disabled) reports a Stage that
selects Targets as Stalled instead of promoting to it, and a Promotion that
names a Target fails.
Targets are created, replaced and deleted through POST, PUT and
DELETE on the same path, with a Target resource as the body; the generic
/v1beta1/resources endpoints refuse kind: Target, so kargo apply of a
Target manifest fails with a pointer to those endpoints. A PUT that carries
the metadata.resourceVersion or metadata.uid it read is refused with
409 Conflict if the Target changed in between. There is no watch on
Targets: the database offers no change feed, so the UI polls the list every
few seconds instead.
To open a psql session against it:
make db-shell
This runs psql inside the PostgreSQL pod, so nothing needs to be installed
locally and the client always matches the server.
Migrations
Migrations are embedded in the control plane binary and applied by its
migrate subcommand, which waits for the database to accept a connection,
takes a lock on the schema so concurrent runners serialize, and applies
whatever is pending. The chart's migration Job runs it on every install and
upgrade, and Tilt runs that same Job as the db-migrate resource, so the Job
and its connection wiring are exercised in development too. Like the other
workloads, it deploys once at startup. After that, a change under
db/migrations/ recompiles the binary and rebuilds the image, and Tilt marks
db-migrate as having pending changes, but applies them only when you
trigger it, from the Tilt UI or with:
hack/bin/tilt trigger db-migrate
This keeps a migration you are still editing from being applied early. A
failed migration is retried up to the Job's backoffLimit, and each attempt's
logs appear in Tilt.
To run the same subcommand from your working tree, outside Tilt:
make db-migrate
To run the database integration tests, which create and drop their own databases on the development server:
make test-db
Both target DATABASE_URL, which defaults to the development database.
The subcommand only applies migrations. For inspecting, rolling back, or resetting, use the Goose tool directly after configuring your shell:
export GOOSE_DRIVER=postgres
export GOOSE_DBSTRING='postgres://kargo:kargo@127.0.0.1:15432/kargo?sslmode=disable'
export GOOSE_MIGRATION_DIR=db/migrations
go tool goose status
Migrations must be forward-only and compatible with the previous release's
code: during a rollout, and after a rollback, pods built from the previous
release run against the migrated schema. Add a column in one release and
remove its predecessor in the next rather than renaming in place. Keep a
Down section for local development, but do not rely on it in production.
Creating a migration
Create the migration in place:
go tool goose -dir db/migrations create create_widgets sql
Replace the generated SQL with:
-- +goose Up
CREATE TABLE widgets (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
-- +goose Down
DROP TABLE widgets;
Trigger db-migrate, then inspect the result:
hack/bin/tilt trigger db-migrate
make db-shell
\d widgets
Goose tracks migrations by version and never re-applies one it has already
run. If an applied migration turns out to be wrong, roll it back with
go tool goose down before editing it. Once a migration has merged, add a new
one instead of changing it.
Rollback and reset are explicit operations. With the environment variables
above set, go tool goose down rolls back the latest migration, and
go tool goose reset rolls back all migrations. Both may delete data, depending
on the migrations' down statements. Run make db-migrate to reapply them.
PostgreSQL uses a persistent volume from the cluster's default StorageClass.
Data survives pod replacement, stopping/restarting Tilt, and
make hack-tilt-down: Tilt retains namespaces by default, and the StatefulSet
retains its volume claim when deleted. Deleting the kargo namespace or the
data-kargo-postgres-0 volume claim removes this persistence; the StorageClass's
reclaim policy determines whether the backing volume is also deleted. Treat
this database as disposable development data.
Tracing database operations
Connection pools created with database.NewPool record an OpenTelemetry span
for every query and batch, using the same tracing setup as the rest of the
control plane. Spans are named after the sqlc query
that produced them, for example UpsertProject, and carry the operation,
the number of rows returned, and the server's SQLSTATE code when a query
fails. For sqlc queries, which are always parameterized, the query text is
recorded as well; parameters never are. Lock timeouts, deadlocks, and serialization
failures each have their own code, so they can be told apart from other
errors.
A span cannot distinguish a slow query from one that waited on a lock held by
another session. The development values set log_lock_waits so the
PostgreSQL instance logs every lock wait longer than one second, naming both
the waiting and the holding session, and the pod's logs answer that question:
kubectl logs -n kargo kargo-postgres-0 | grep 'still waiting'
Every component's sessions carry an application_name, so the log lines and
pg_stat_activity show which component held the lock.
Generating query code with sqlc
Kargo uses sqlc to generate type-safe Go code from SQL.
sqlc.yaml reads the schema from the Goose migrations in db/migrations/ and
the queries in db/queries/, and writes pgx/v5 code to pkg/database/. While
Tilt is running, the codegen-db resource regenerates this code whenever either
directory changes, and the back end recompiles with the result. To regenerate
manually:
make codegen-db
make codegen also runs this step. Commit the generated code together with the
SQL that produced it.
Database integration tests
Tests in pkg/database that need a real database are tagged integration and
run against a disposable schema that they create and drop, so existing tables
are left untouched. They require a database user allowed to create schemas
and target DATABASE_URL, which defaults to the development database:
make test-db
Contributing to Documentation
Contributors should ensure that their changes are accompanied by relevant documentation updates. This helps maintain the project's sustainability. Pull requests with corresponding documentation updates are more likely to be merged faster.
To make this process smoother, you can refer to Docusaurus for guidance on writing and maintaining docs effectively.
Previewing Doc Changes Locally
After making your changes, preview the documentation locally to ensure everything renders correctly. You can either run it in a container or natively on your system.
To build and serve the docs inside a container:
make hack-serve-docs
If you wish to opt-out of executing code-generation within a container (for
performance reasons, perhaps), drop the hack- prefix from the target to run the docs natively on your system:
make serve-docs
This will require quite a variety of tools to be installed locally, so we do not recommend this if you can avoid it.