Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ need to re-export `KINETIC_*` env vars each time you switch.
| `KINETIC_CLUSTER` | CLI + decorators | `kinetic-cluster` | GKE cluster name. |
| `KINETIC_NAMESPACE` | CLI + decorators | `default` | Kubernetes namespace. |
| `KINETIC_BASE_IMAGE_REPO` | Decorator (prebuilt mode) | `kinetic` | Repo for prebuilt base images. See [Execution Modes](guides/execution_modes.md). |
| `KINETIC_OUTPUT_DIR` | CLI + remote pod | `gs://{bucket}/outputs/{job_id}` | Per-job durable artifact prefix. See [Checkpointing](guides/checkpointing.md). |
| `KINETIC_OUTPUT_DIR` | Library (submit) + remote pod | `gs://{bucket}/outputs/{job_id}` | Per-job durable artifact prefix. Kinetic reads this variable at submit time and sets it in the pod. Kinetic has no equivalent CLI flag. See [Checkpointing](guides/checkpointing.md). |
| `KINETIC_RESERVATION` | `kinetic pool add` | _(unset)_ | GCP capacity reservation to consume. Pool-level config, not a per-job setting. |
| `KINETIC_LOG_LEVEL` | Library | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `FATAL`. |
| `KINETIC_DEBUG_WAIT_TIMEOUT` | Library + remote pod | `600` | Seconds the remote pod waits for a debugger client to attach when `debug=True`. Applies on both sides (local `debug_attach()` and the pod's debugpy server). |
Expand Down Expand Up @@ -48,7 +48,7 @@ wins:
| Zone | `zone=` | `--zone` | `KINETIC_ZONE` | `zone` | `us-central1-a` |
| Cluster | `cluster=` | `--cluster` | `KINETIC_CLUSTER` | `cluster` | `kinetic-cluster` |
| Namespace | `namespace=` | `--namespace` | `KINETIC_NAMESPACE` | `namespace` | `default` |
| Output dir | `output_dir=` | `--output-dir` | `KINETIC_OUTPUT_DIR` | _(n/a)_ | `gs://{bucket}/outputs/{job_id}` |
| Output dir | `output_dir=` | _(n/a)_ | `KINETIC_OUTPUT_DIR` | _(n/a)_ | `gs://{bucket}/outputs/{job_id}` |
| Base image repo | `base_image_repo=` | `kinetic build-image --repo` | `KINETIC_BASE_IMAGE_REPO` | _(n/a)_ | `kinetic` |
| Reservation\* | _(n/a)_ | `kinetic pool add --reservation` | `KINETIC_RESERVATION` | _(n/a)_ | _(unset)_ |

Expand Down
12 changes: 6 additions & 6 deletions docs/guides/checkpointing.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,12 @@ is the Kinetic cluster name (from `KINETIC_CLUSTER`, defaulting to
`kinetic-cluster`). The bucket is created by `kinetic up` and reused
across all jobs submitted to that cluster.

You can override it per job by passing `output_dir=` to the decorator,
setting `KINETIC_OUTPUT_DIR` in your local environment before
submission, or (when inspecting an existing job from the CLI) passing
`--output-dir` to the relevant `kinetic jobs` subcommand. See the
precedence table in [Configuration](../configuration.md) for how these
resolution paths combine.
You can override the default for one job. Pass `output_dir=` to the
decorator, or set `KINETIC_OUTPUT_DIR` in your local environment before
you submit the job. Kinetic reads both sources at submit time. The
output directory therefore does not change while the job runs. The
precedence table in [Configuration](../configuration.md) shows how these
sources combine.

## Recommended directory layout

Expand Down
4 changes: 2 additions & 2 deletions docs/guides/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def train(): ...

Your custom image must:

1. Include `cloudpickle`, `google-cloud-storage`, and a compatible Python environment.
1. Include `cloudpickle`, `google-cloud-storage`, `absl-py`, and a compatible Python environment. The runner imports these three packages at startup.
2. Include the necessary dependencies for your function.
3. Be accessible from the GKE nodes (e.g., Artifact Registry in the same GCP project, or a public registry).

Expand Down Expand Up @@ -173,7 +173,7 @@ kinetic build-image --repo myuser/kinetic --tag v2.0.0
| `--repo` | Image repository (Docker Hub or Artifact Registry). Omit to select interactively. |
| `--category` | Accelerator categories to build: `cpu`, `gpu`, `tpu` (default: all). Repeatable. |
| `--tag` | Image version tag (default: kinetic package version). |
| `--dockerfile` | Path to a custom Dockerfile. Must install `uv`, `cloudpickle`, `google-cloud-storage`, and `COPY remote_runner.py` to `/app/`. |
| `--dockerfile` | Path to a custom Dockerfile. The Dockerfile must install `uv`, `cloudpickle`, `google-cloud-storage`, and `absl-py`. It must also copy `remote_runner.py` to `/app/`. |
| `--update-credentials` | Re-enter Docker Hub credentials even if they already exist in Secret Manager. |
| `--yes`, `-y` | Skip confirmation prompt. |
| `--project` | GCP project ID (default: `KINETIC_PROJECT`). |
Expand Down
2 changes: 1 addition & 1 deletion kinetic/backend/execution_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def test_from_params_no_project_raises(self):
}
with (
mock.patch.dict(os.environ, env, clear=True),
self.assertRaisesRegex(ValueError, "project must be specified"),
self.assertRaisesRegex(ValueError, "No GCP project configured"),
):
JobContext.from_params(
func=self._make_func(),
Expand Down
7 changes: 4 additions & 3 deletions kinetic/cli/commands/build_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,10 @@ def _prompt_categories():
"--dockerfile",
type=click.Path(exists=True, dir_okay=False),
default=None,
help="Path to a custom Dockerfile. When set, it is used instead of the "
"auto-generated one. The Dockerfile must install uv, cloudpickle, "
"google-cloud-storage, and COPY remote_runner.py to /app/.",
help="Path to a custom Dockerfile. Kinetic uses this Dockerfile instead "
"of the auto-generated one. The Dockerfile must install uv, cloudpickle, "
"google-cloud-storage, and absl-py. It must also copy remote_runner.py "
"to /app/.",
)
@click.option(
"--update-credentials",
Expand Down
21 changes: 21 additions & 0 deletions kinetic/cli/commands/build_image_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,27 @@ def test_empty_categories_exits_early(self):
mock_build.assert_not_called()


class TestDockerfileHelp(absltest.TestCase):
def test_lists_every_runtime_import_of_remote_runner(self):
"""A custom Dockerfile must cover remote_runner.py's module-level imports.

Missing any of these fails the pod at startup, before the payload
is even unpickled, so the help text has to name all of them.
"""
# Assert on the authored string, not `--help` output: Click wraps at
# hyphens, so a rendered "google-cloud-storage" can arrive split
# across two lines.
param = next(p for p in build_image.params if p.name == "dockerfile")
for package in ("uv", "cloudpickle", "google-cloud-storage", "absl-py"):
self.assertIn(package, param.help)
self.assertIn("remote_runner.py", param.help)

def test_dockerfile_flag_is_documented(self):
result = CliRunner().invoke(build_image, ["--help"])
self.assertEqual(result.exit_code, 0, result.output)
self.assertIn("--dockerfile", result.output)


class TestIsArRepo(absltest.TestCase):
def test_detects_ar_uri(self):
self.assertTrue(_is_ar_repo("us-docker.pkg.dev/proj/my-repo"))
Expand Down
6 changes: 4 additions & 2 deletions kinetic/cli/commands/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ def _ensure_project(project):
"""Raise a clear CLI error if project is not set."""
if not project:
raise click.UsageError(
"Project is required. Set --project or KINETIC_PROJECT."
"No GCP project configured. Run 'kinetic init' to save an active "
"profile, or override it for this command with --project or "
"KINETIC_PROJECT."
)


Expand All @@ -33,7 +35,7 @@ def jobs():

@jobs.command("list")
@jobs_options
def list_command(project, zone, cluster_name, namespace, output_dir):
def list_command(project, zone, cluster_name, namespace):
"""List live async jobs."""
_ensure_project(project)

Expand Down
18 changes: 18 additions & 0 deletions kinetic/cli/commands/jobs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ def test_list_with_jobs(self, mock_lj):
# Should show accelerator, not call status()
self.assertIn("l4", result.output)

def test_no_output_dir_flag(self):
"""--output-dir was never wired to anything; it must stay removed."""
runner = CliRunner()
result = runner.invoke(
jobs,
["list", "--project", "proj", "--output-dir", "gs://bucket/out"],
)
self.assertNotEqual(result.exit_code, 0)
self.assertIn("no such option", result.output.lower())


class TestJobsStatus(absltest.TestCase):
@mock.patch(f"{_JOBS_MODULE}._attach")
Expand Down Expand Up @@ -357,6 +367,14 @@ def test_list_requires_project(self):
result = runner.invoke(jobs, ["list"])
self.assertNotEqual(result.exit_code, 0)

def test_error_points_at_kinetic_init_first(self):
runner = CliRunner()
with mock.patch.dict("os.environ", {}, clear=True):
result = runner.invoke(jobs, ["list"])
self.assertIn("kinetic init", result.output)
self.assertIn("--project", result.output)
self.assertIn("KINETIC_PROJECT", result.output)


if __name__ == "__main__":
absltest.main()
8 changes: 1 addition & 7 deletions kinetic/cli/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def force_destroy_option(f):
def jobs_options(f):
"""Shared options for ``kinetic jobs`` subcommands.

Extends ``common_options`` with ``--namespace`` and `--output-dir`.
Extends ``common_options`` with ``--namespace``.
"""
f = common_options(f)
f = click.option(
Expand All @@ -82,10 +82,4 @@ def jobs_options(f):
show_default=True,
help="Kubernetes namespace [env: KINETIC_NAMESPACE]",
)(f)
f = click.option(
"--output-dir",
envvar="KINETIC_OUTPUT_DIR",
default=None,
help="Output directory [env: KINETIC_OUTPUT_DIR]",
)(f)
return f
6 changes: 4 additions & 2 deletions kinetic/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ def get_required_project(project: str | None = None) -> str:
project = project or get_default_project()
if not project:
raise ValueError(
"project must be specified or set KINETIC_PROJECT "
"(or GOOGLE_CLOUD_PROJECT) environment variable"
"No GCP project configured. Run 'kinetic init' to save an active "
"profile, or override it by passing project= (CLI: --project) or "
"by setting the KINETIC_PROJECT (or GOOGLE_CLOUD_PROJECT) "
"environment variable"
)
return project

Expand Down
24 changes: 24 additions & 0 deletions kinetic/constants_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
DEFAULT_REGION,
DEFAULT_ZONE,
get_default_zone,
get_required_project,
zone_to_ar_location,
zone_to_region,
)
Expand Down Expand Up @@ -118,6 +119,29 @@ def test_default_constants(self, constant, expected_value):
}


class TestGetRequiredProject(absltest.TestCase):
def test_returns_explicit_project(self):
self.assertEqual(get_required_project("explicit"), "explicit")

def test_falls_back_to_env(self):
with mock.patch.dict(
os.environ, {"KINETIC_PROJECT": "from-env"}, clear=True
):
self.assertEqual(get_required_project(), "from-env")

def test_error_points_at_kinetic_init_first(self):
with (
mock.patch.dict(os.environ, {}, clear=True),
self.assertRaises(ValueError) as ctx,
):
get_required_project()
message = str(ctx.exception)
self.assertIn("kinetic init", message)
self.assertIn("project=", message)
self.assertIn("KINETIC_PROJECT", message)
self.assertIn("GOOGLE_CLOUD_PROJECT", message)


class TestAllZonesMapping(absltest.TestCase):
def test_all_zones_map_to_valid_ar_location(self):
for zone in _ZONES:
Expand Down
3 changes: 2 additions & 1 deletion kinetic/infra/container_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,7 +854,8 @@ def build_and_push_prebuilt_image(
dependencies (JAX + keras + cloudpickle + google-cloud-storage) is
auto-generated. A custom *dockerfile* is used verbatim — the caller
must ensure it installs `uv`, `cloudpickle`,
`google-cloud-storage` and copies `remote_runner.py` to
`google-cloud-storage`, `absl-py` (all imported by
`remote_runner.py` at module load) and copies `remote_runner.py` to
`/app/remote_runner.py`.

For Docker Hub, credentials are read from GCP Secret Manager
Expand Down
Loading