Skip to content

Commit 420b1d4

Browse files
committed
Merge remote-tracking branch 'origin/chore/improve_artifact_handling' into dev
2 parents 541651b + fe7cde5 commit 420b1d4

12 files changed

Lines changed: 104 additions & 54 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddArtifactExpirationDate1778500427739 implements MigrationInterface {
4+
name = 'AddArtifactExpirationDate1778500427739';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`ALTER TABLE "action" ADD "artifactExpirationDate" TIMESTAMP`,
9+
);
10+
await queryRunner.query(
11+
`ALTER TYPE "public"."action_artifacts_enum" ADD VALUE '50'`,
12+
);
13+
}
14+
15+
public async down(queryRunner: QueryRunner): Promise<void> {
16+
await queryRunner.query(
17+
`ALTER TABLE "action" DROP COLUMN "artifactExpirationDate"`,
18+
);
19+
}
20+
}

backend/src/serialization/action.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@kleinkram/api-dto';
77
import { ActionEntity } from '@kleinkram/backend-common/entities/action/action.entity';
88
import { WorkerEntity } from '@kleinkram/backend-common/entities/worker/worker.entity';
9+
import { ArtifactState } from '@kleinkram/shared';
910
import { actionTemplateEntityToDto } from './action-template';
1011
import { missionEntityToDto, userEntityToDto } from './index';
1112

@@ -52,9 +53,21 @@ export const actionEntityToDto = (action: ActionEntity): ActionDto => {
5253
runtime = (end - action.actionContainerStartedAt.getTime()) / 1000;
5354
}
5455

56+
let computedArtifactState = action.artifacts;
57+
if (
58+
action.artifactExpirationDate &&
59+
action.artifactExpirationDate < new Date() &&
60+
action.artifacts === ArtifactState.UPLOADED
61+
) {
62+
computedArtifactState = ArtifactState.EXPIRED;
63+
}
64+
5565
return {
56-
artifactUrl: action.artifact_path ?? '',
57-
artifacts: action.artifacts,
66+
artifactUrl:
67+
computedArtifactState === ArtifactState.EXPIRED
68+
? ''
69+
: (action.artifact_path ?? ''),
70+
artifacts: computedArtifactState,
5871
artifactSize: action.artifact_size,
5972
artifactFiles: action.artifact_files,
6073
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition

backend/src/services/action.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export class ActionService {
182182

183183
const dto = actionEntityToDto(action);
184184

185-
if (action.artifacts === ArtifactState.UPLOADED) {
185+
if (dto.artifacts === ArtifactState.UPLOADED) {
186186
dto.artifactUrl = await this.generateArtifactUrl(
187187
dto.artifactUrl,
188188
action,

cli/kleinkram/api/deser.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from kleinkram.errors import ParsingError
1616
from kleinkram.models import ActionTemplate
17+
from kleinkram.models import ArtifactState
1718
from kleinkram.models import Execution
1819
from kleinkram.models import File
1920
from kleinkram.models import FileState
@@ -81,6 +82,8 @@ class ExecutionObjectKeys(str, Enum):
8182
UPDATED_AT = "updatedAt"
8283
LOGS = "logs"
8384
ARTIFACT_URL = "artifactUrl"
85+
ARTIFACT_STATE = "artifacts"
86+
ARTIFACT_SIZE = "artifactSize"
8487

8588

8689
class TemplateObjectKeys(str, Enum):
@@ -269,6 +272,9 @@ def _parse_execution(execution_object: ExecutionObject) -> Execution:
269272
state = execution_object[ExecutionObjectKeys.STATE]
270273
state_cause = execution_object[ExecutionObjectKeys.STATE_CAUSE]
271274
artifact_url = execution_object.get(ExecutionObjectKeys.ARTIFACT_URL)
275+
raw_state = execution_object.get(ExecutionObjectKeys.ARTIFACT_STATE)
276+
artifact_state = ArtifactState(raw_state) if raw_state is not None else None
277+
artifact_size = execution_object.get(ExecutionObjectKeys.ARTIFACT_SIZE)
272278
created_at = _parse_datetime(execution_object[ExecutionObjectKeys.CREATED_AT])
273279
updated_at = (
274280
_parse_datetime(execution_object[ExecutionObjectKeys.UPDATED_AT])
@@ -307,6 +313,8 @@ def _parse_execution(execution_object: ExecutionObject) -> Execution:
307313
state=state,
308314
state_cause=state_cause,
309315
artifact_url=artifact_url,
316+
artifact_state=artifact_state,
317+
artifact_size=artifact_size,
310318
created_at=created_at,
311319
updated_at=updated_at,
312320
mission_id=mission_id,

cli/kleinkram/cli/_executions.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,12 @@ def logs(
257257
@executions_typer.command(name="download", help=DOWNLOAD_HELP)
258258
def download_artifacts(
259259
execution: str = typer.Argument(..., metavar="EXECUTION_ID", help="The ID of the execution to download artifacts for."),
260-
output: Optional[str] = typer.Option(None, "--output", "-o", help="Path or filename to save the artifacts to."),
260+
output_dir: Optional[str] = typer.Option(
261+
None, "--output-dir", "-o", help="Directory to save the artifacts to (defaults to current directory)."
262+
),
263+
filename: Optional[str] = typer.Option(
264+
None, "--filename", "-f", help="Filename to save the artifact as (must end in .tar.gz)."
265+
),
261266
extract: bool = typer.Option(
262267
False,
263268
"--extract",
@@ -279,7 +284,8 @@ def download_artifacts(
279284
kleinkram.core.download_artifact(
280285
client=client,
281286
execution_id=execution_id,
282-
output=output,
287+
output_dir=output_dir,
288+
filename=filename,
283289
extract=extract,
284290
verbose=get_shared_state().verbose,
285291
)

cli/kleinkram/core.py

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
from uuid import UUID
3030

3131
import httpx
32-
import requests
3332
from rich.console import Console
3433
from tqdm import tqdm
3534

@@ -45,6 +44,7 @@
4544
from kleinkram.errors import InvalidFileQuery
4645
from kleinkram.errors import MissionNotFound
4746
from kleinkram.errors import TemplateNotFound
47+
from kleinkram.models import ArtifactState
4848
from kleinkram.models import FileState
4949
from kleinkram.models import FileVerificationStatus
5050
from kleinkram.printing import files_to_table
@@ -65,7 +65,8 @@ def download_artifact(
6565
*,
6666
client: AuthenticatedClient,
6767
execution_id: UUID,
68-
output: Optional[str] = None,
68+
output_dir: Optional[str] = None,
69+
filename: Optional[str] = None,
6970
extract: bool = False,
7071
verbose: bool = False,
7172
) -> str:
@@ -74,7 +75,8 @@ def download_artifact(
7475
7576
Args:
7677
execution_id: The ID of the execution to download artifacts for.
77-
output: Path or filename to save the artifacts to.
78+
output_dir: Directory to save the artifacts to (optional).
79+
filename: Custom filename for the downloaded artifact (optional, must end with .tar.gz).
7880
extract: Automatically extract the archive after downloading.
7981
verbose: Print progress and extraction info.
8082
@@ -85,52 +87,41 @@ def download_artifact(
8587
# Fetch Execution Details
8688
execution = kleinkram.api.routes.get_execution(client, execution_id=execution_id)
8789

90+
if execution.artifact_state == ArtifactState.EXPIRED:
91+
raise ValueError(f"Artifacts for execution {execution_id} have expired and therefore been deleted.")
92+
8893
if not execution.artifact_url:
94+
raise ValueError(f"No artifacts found for execution {execution_id}. The execution might not be finished yet.")
95+
96+
if not execution.artifact_size:
8997
raise ValueError(
90-
f"No artifacts found for execution {execution_id}. The execution might not be finished or artifacts expired."
98+
f"Artifact size is missing, empty, or corrupted for execution {execution_id}, cannot proceed with download."
9199
)
92100

93101
if verbose:
94102
print(f"Downloading artifacts for execution {execution_id}...")
95103

96-
# Grab the headers to determine filename and size
97-
try:
98-
with requests.get(execution.artifact_url, stream=True) as r:
99-
r.raise_for_status()
100-
headers = r.headers.copy()
101-
except requests.exceptions.RequestException as e:
102-
raise ValueError(f"Error fetching artifact headers: {e}")
103-
104-
# Determine Filename
105-
filename = output
106-
if not filename:
107-
filename = kleinkram.api.file_transfer._get_filename_from_cd(headers.get("content-disposition"))
104+
if filename:
105+
if not filename.endswith(".tar.gz"):
106+
raise ValueError(f"Filename must end with .tar.gz, got: {filename}")
108107

109108
if not filename:
110-
filename = f"{execution_id}.tar.gz"
109+
filename = f"{execution.template_name}-{execution_id}.tar.gz"
111110

112-
# If output is a directory, join with filename
113-
if output and os.path.isdir(output):
114-
filename = os.path.join(
115-
output,
116-
kleinkram.api.file_transfer._get_filename_from_cd(headers.get("content-disposition")) or f"{execution_id}.tar.gz",
117-
)
111+
if output_dir and not os.path.isdir(output_dir):
112+
raise ValueError(f"Output directory {output_dir} does not exist or is not a directory.")
118113

119-
total_length_raw = headers.get("content-length")
120-
if total_length_raw is None:
121-
raise ValueError(
122-
f"Cannot determine artifact size for execution {execution_id}: "
123-
"the server did not return a content-length header."
124-
)
125-
total_length = int(total_length_raw)
114+
filepath = Path(os.path.join(output_dir, filename) if output_dir else filename)
126115

127-
filepath = Path(filename)
116+
if verbose:
117+
print(f"Artifact size: {execution.artifact_size} bytes")
118+
print(f"Saving artifact to: {filepath}...")
128119

129120
# Download the file using _url_download
130121
kleinkram.api.file_transfer._url_download(
131122
url=execution.artifact_url,
132123
path=filepath,
133-
size=total_length,
124+
size=execution.artifact_size,
134125
overwrite=True, # overwrite if we run the CLI command directly
135126
verbose=verbose,
136127
)

cli/kleinkram/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import field
55
from datetime import datetime
66
from enum import Enum
7+
from enum import IntEnum
78
from typing import Dict
89
from typing import List
910
from uuid import UUID
@@ -85,6 +86,14 @@ class ExecutionStatus(str, Enum):
8586
CANCELLED = "Cancelled"
8687

8788

89+
class ArtifactState(IntEnum):
90+
AWAITING_ACTION = 10
91+
UPLOADING = 20
92+
UPLOADED = 30
93+
ERROR = 40
94+
EXPIRED = 50
95+
96+
8897
@dataclass(frozen=True)
8998
class LogEntry:
9099
timestamp: datetime
@@ -98,6 +107,8 @@ class Execution:
98107
state: str
99108
state_cause: str | None
100109
artifact_url: str | None
110+
artifact_state: ArtifactState | None
111+
artifact_size: int | None
101112
created_at: datetime
102113
updated_at: datetime | None
103114
project_name: str

cli/kleinkram/wrappers.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ def download(
161161

162162
def download_artifact(
163163
execution_id: IdLike,
164-
output: Optional[PathLike] = None,
164+
output_dir: Optional[PathLike] = None,
165+
filename: Optional[str] = None,
165166
extract: bool = False,
166167
verbose: bool = False,
167168
*,
@@ -172,7 +173,8 @@ def download_artifact(
172173
173174
Args:
174175
execution_id: The ID of the execution to download artifacts for.
175-
output: Path or filename to save the artifacts to.
176+
output_dir: Directory to save the artifacts to (optional, defaults to current directory).
177+
filename: Filename to save the artifact as (optional, must end in .tar.gz).
176178
extract: Automatically extract the archive after downloading.
177179
verbose: Print progress and extraction info.
178180
@@ -183,7 +185,8 @@ def download_artifact(
183185
return kleinkram.core.download_artifact(
184186
client=client,
185187
execution_id=parse_uuid_like(execution_id),
186-
output=str(parse_path_like(output)) if output else None,
188+
output_dir=str(output_dir) if output_dir else None,
189+
filename=filename,
187190
extract=extract,
188191
verbose=verbose,
189192
)

frontend/src/components/actions/action-details-execution-tab.vue

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,8 @@
116116
unelevated
117117
class="bg-button-secondary text-on-color"
118118
icon="sym_o_download"
119-
:disable="isExpired"
120119
@click="openArtifact"
121120
>
122-
<q-tooltip v-if="isExpired">
123-
Artifacts are only available for 3 months.
124-
</q-tooltip>
125121
</q-btn>
126122
<div v-else class="text-grey-7">
127123
{{ artifactStateText }}
@@ -431,6 +427,9 @@ const artifactStateText = computed(() => {
431427
}
432428
433429
switch (props.action.artifacts) {
430+
case ArtifactState.EXPIRED: {
431+
return 'Artifacts expired and deleted';
432+
}
434433
case ArtifactState.UPLOADING: {
435434
return 'Uploading...';
436435
}
@@ -459,16 +458,6 @@ const showArtifactTree = computed(() => {
459458
);
460459
});
461460
462-
const isExpired = computed(() => {
463-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
464-
if (!props.action.createdAt) return false;
465-
const created = new Date(props.action.createdAt);
466-
const now = new Date();
467-
// 3 months ago
468-
const threeMonthsAgo = new Date(now.setMonth(now.getMonth() - 3));
469-
return created < threeMonthsAgo;
470-
});
471-
472461
const openArtifact = (): void => {
473462
if (props.action.artifactUrl) {
474463
window.open(props.action.artifactUrl, '_blank');

packages/backend-common/src/entities/action/action.entity.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ export class ActionEntity extends BaseEntity {
116116
// eslint-disable-next-line @typescript-eslint/naming-convention
117117
artifact_files?: string[];
118118

119+
@Column({ nullable: true })
120+
artifactExpirationDate?: Date;
121+
119122
@OneToOne(() => ApiKeyEntity, (apikey) => apikey.action)
120123
@JoinColumn()
121124
key?: ApiKeyEntity;

0 commit comments

Comments
 (0)