Merge pull request 'feat(executor): run node, docker, composite and local actions (CI-2 phase F)' (#12) from feat/ci2-actions into main
44 files · +6769 -273
M go.mod +1 -1
| @@ -3,7 +3,7 @@ module bnema.dev/ermite-runner | |||
| 3 | 3 | go 1.27.1 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362 | |
| 6 | + | bnema.dev/ermite-actions v0.1.1-0.20261013141203-3425eb2aa0ab | |
| 7 | 7 | bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a | |
| 8 | 8 | connectrpc.com/connect v1.21.0 | |
| 9 | 9 | github.com/bnema/zerowrap v1.4.1 | |
M go.sum +2 -2 generated file: collapsed
M internal/execstream/exec.proto +37 -0
| @@ -27,6 +27,8 @@ message Frame { | |||
| 27 | 27 | JobResult job_result = 9; // executor -> host, before Exit | |
| 28 | 28 | StepAnnotations step_annotations = 10; // executor -> host, before the StepCompleted of the step | |
| 29 | 29 | StepSummary step_summary = 11; // executor -> host, before the StepCompleted of the step | |
| 30 | + | ResolveRequest resolve_request = 12; // executor -> host | |
| 31 | + | ResolveResponse resolve_response = 13; // host -> executor, answers a ResolveRequest | |
| 30 | 32 | } | |
| 31 | 33 | } | |
| 32 | 34 | ||
| @@ -192,3 +194,38 @@ message JobResult { | |||
| 192 | 194 | string error = 3; | |
| 193 | 195 | bool runner_shutdown = 4; | |
| 194 | 196 | } | |
| 197 | + | ||
| 198 | + | // ResolveRequest asks the host to resolve actions with RunnerService. | |
| 199 | + | // ResolveActions: the job token stays in the host, so the executor cannot call | |
| 200 | + | // it. The executor sends one per level of composite actions (the runner's own | |
| 201 | + | // "one more round per level") and waits for the ResolveResponse with the same id. | |
| 202 | + | message ResolveRequest { | |
| 203 | + | int64 id = 1; | |
| 204 | + | repeated string uses = 2; // "owner/repo[/path]@ref", at most 100 | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // ResolveResponse answers a ResolveRequest. error is set when the call itself | |
| 208 | + | // failed (the executor fails the job); actions has one entry per uses otherwise. | |
| 209 | + | message ResolveResponse { | |
| 210 | + | int64 id = 1; | |
| 211 | + | string error = 2; | |
| 212 | + | repeated ResolvedAction actions = 3; | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // ResolvedAction mirrors ermite.ResolvedAction (the token is a secret: the | |
| 216 | + | // executor masks it). | |
| 217 | + | message ResolvedAction { | |
| 218 | + | string ref = 1; | |
| 219 | + | string name_with_owner = 2; | |
| 220 | + | string path = 3; | |
| 221 | + | string resolved_sha = 4; | |
| 222 | + | string tarball_url = 5; | |
| 223 | + | string token = 6; | |
| 224 | + | string source = 7; | |
| 225 | + | string error = 8; | |
| 226 | + | // auth_origin (scheme://host[:port], as the sandbox reaches it) is the only | |
| 227 | + | // origin the token may be sent to; empty means the token is never sent. The | |
| 228 | + | // host sets it for archives served by the ermite server, from its own | |
| 229 | + | // configuration, never from the URL alone. | |
| 230 | + | string auth_origin = 9; | |
| 231 | + | } | |
M internal/execstream/execpb/exec.pb.go +315 -22
| @@ -257,6 +257,8 @@ type Frame struct { | |||
| 257 | 257 | // *Frame_JobResult | |
| 258 | 258 | // *Frame_StepAnnotations | |
| 259 | 259 | // *Frame_StepSummary | |
| 260 | + | // *Frame_ResolveRequest | |
| 261 | + | // *Frame_ResolveResponse | |
| 260 | 262 | Body isFrame_Body `protobuf_oneof:"body"` | |
| 261 | 263 | unknownFields protoimpl.UnknownFields | |
| 262 | 264 | sizeCache protoimpl.SizeCache | |
| @@ -398,6 +400,24 @@ func (x *Frame) GetStepSummary() *StepSummary { | |||
| 398 | 400 | return nil | |
| 399 | 401 | } | |
| 400 | 402 | ||
| 403 | + | func (x *Frame) GetResolveRequest() *ResolveRequest { | |
| 404 | + | if x != nil { | |
| 405 | + | if x, ok := x.Body.(*Frame_ResolveRequest); ok { | |
| 406 | + | return x.ResolveRequest | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | return nil | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func (x *Frame) GetResolveResponse() *ResolveResponse { | |
| 413 | + | if x != nil { | |
| 414 | + | if x, ok := x.Body.(*Frame_ResolveResponse); ok { | |
| 415 | + | return x.ResolveResponse | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | return nil | |
| 419 | + | } | |
| 420 | + | ||
| 401 | 421 | type isFrame_Body interface { | |
| 402 | 422 | isFrame_Body() | |
| 403 | 423 | } | |
| @@ -446,6 +466,14 @@ type Frame_StepSummary struct { | |||
| 446 | 466 | StepSummary *StepSummary `protobuf:"bytes,11,opt,name=step_summary,json=stepSummary,proto3,oneof"` // executor -> host, before the StepCompleted of the step | |
| 447 | 467 | } | |
| 448 | 468 | ||
| 469 | + | type Frame_ResolveRequest struct { | |
| 470 | + | ResolveRequest *ResolveRequest `protobuf:"bytes,12,opt,name=resolve_request,json=resolveRequest,proto3,oneof"` // executor -> host | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | type Frame_ResolveResponse struct { | |
| 474 | + | ResolveResponse *ResolveResponse `protobuf:"bytes,13,opt,name=resolve_response,json=resolveResponse,proto3,oneof"` // host -> executor, answers a ResolveRequest | |
| 475 | + | } | |
| 476 | + | ||
| 449 | 477 | func (*Frame_Hello) isFrame_Body() {} | |
| 450 | 478 | ||
| 451 | 479 | func (*Frame_Run) isFrame_Body() {} | |
| @@ -468,6 +496,10 @@ func (*Frame_StepAnnotations) isFrame_Body() {} | |||
| 468 | 496 | ||
| 469 | 497 | func (*Frame_StepSummary) isFrame_Body() {} | |
| 470 | 498 | ||
| 499 | + | func (*Frame_ResolveRequest) isFrame_Body() {} | |
| 500 | + | ||
| 501 | + | func (*Frame_ResolveResponse) isFrame_Body() {} | |
| 502 | + | ||
| 471 | 503 | type Hello struct { | |
| 472 | 504 | state protoimpl.MessageState `protogen:"open.v1"` | |
| 473 | 505 | Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // runner version of the executor | |
| @@ -1416,12 +1448,244 @@ func (x *JobResult) GetRunnerShutdown() bool { | |||
| 1416 | 1448 | return false | |
| 1417 | 1449 | } | |
| 1418 | 1450 | ||
| 1451 | + | // ResolveRequest asks the host to resolve actions with RunnerService. | |
| 1452 | + | // ResolveActions: the job token stays in the host, so the executor cannot call | |
| 1453 | + | // it. The executor sends one per level of composite actions (the runner's own | |
| 1454 | + | // "one more round per level") and waits for the ResolveResponse with the same id. | |
| 1455 | + | type ResolveRequest struct { | |
| 1456 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 1457 | + | Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` | |
| 1458 | + | Uses []string `protobuf:"bytes,2,rep,name=uses,proto3" json:"uses,omitempty"` // "owner/repo[/path]@ref", at most 100 | |
| 1459 | + | unknownFields protoimpl.UnknownFields | |
| 1460 | + | sizeCache protoimpl.SizeCache | |
| 1461 | + | } | |
| 1462 | + | ||
| 1463 | + | func (x *ResolveRequest) Reset() { | |
| 1464 | + | *x = ResolveRequest{} | |
| 1465 | + | mi := &file_exec_proto_msgTypes[14] | |
| 1466 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1467 | + | ms.StoreMessageInfo(mi) | |
| 1468 | + | } | |
| 1469 | + | ||
| 1470 | + | func (x *ResolveRequest) String() string { | |
| 1471 | + | return protoimpl.X.MessageStringOf(x) | |
| 1472 | + | } | |
| 1473 | + | ||
| 1474 | + | func (*ResolveRequest) ProtoMessage() {} | |
| 1475 | + | ||
| 1476 | + | func (x *ResolveRequest) ProtoReflect() protoreflect.Message { | |
| 1477 | + | mi := &file_exec_proto_msgTypes[14] | |
| 1478 | + | if x != nil { | |
| 1479 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1480 | + | if ms.LoadMessageInfo() == nil { | |
| 1481 | + | ms.StoreMessageInfo(mi) | |
| 1482 | + | } | |
| 1483 | + | return ms | |
| 1484 | + | } | |
| 1485 | + | return mi.MessageOf(x) | |
| 1486 | + | } | |
| 1487 | + | ||
| 1488 | + | // Deprecated: Use ResolveRequest.ProtoReflect.Descriptor instead. | |
| 1489 | + | func (*ResolveRequest) Descriptor() ([]byte, []int) { | |
| 1490 | + | return file_exec_proto_rawDescGZIP(), []int{14} | |
| 1491 | + | } | |
| 1492 | + | ||
| 1493 | + | func (x *ResolveRequest) GetId() int64 { | |
| 1494 | + | if x != nil { | |
| 1495 | + | return x.Id | |
| 1496 | + | } | |
| 1497 | + | return 0 | |
| 1498 | + | } | |
| 1499 | + | ||
| 1500 | + | func (x *ResolveRequest) GetUses() []string { | |
| 1501 | + | if x != nil { | |
| 1502 | + | return x.Uses | |
| 1503 | + | } | |
| 1504 | + | return nil | |
| 1505 | + | } | |
| 1506 | + | ||
| 1507 | + | // ResolveResponse answers a ResolveRequest. error is set when the call itself | |
| 1508 | + | // failed (the executor fails the job); actions has one entry per uses otherwise. | |
| 1509 | + | type ResolveResponse struct { | |
| 1510 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 1511 | + | Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` | |
| 1512 | + | Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` | |
| 1513 | + | Actions []*ResolvedAction `protobuf:"bytes,3,rep,name=actions,proto3" json:"actions,omitempty"` | |
| 1514 | + | unknownFields protoimpl.UnknownFields | |
| 1515 | + | sizeCache protoimpl.SizeCache | |
| 1516 | + | } | |
| 1517 | + | ||
| 1518 | + | func (x *ResolveResponse) Reset() { | |
| 1519 | + | *x = ResolveResponse{} | |
| 1520 | + | mi := &file_exec_proto_msgTypes[15] | |
| 1521 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1522 | + | ms.StoreMessageInfo(mi) | |
| 1523 | + | } | |
| 1524 | + | ||
| 1525 | + | func (x *ResolveResponse) String() string { | |
| 1526 | + | return protoimpl.X.MessageStringOf(x) | |
| 1527 | + | } | |
| 1528 | + | ||
| 1529 | + | func (*ResolveResponse) ProtoMessage() {} | |
| 1530 | + | ||
| 1531 | + | func (x *ResolveResponse) ProtoReflect() protoreflect.Message { | |
| 1532 | + | mi := &file_exec_proto_msgTypes[15] | |
| 1533 | + | if x != nil { | |
| 1534 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1535 | + | if ms.LoadMessageInfo() == nil { | |
| 1536 | + | ms.StoreMessageInfo(mi) | |
| 1537 | + | } | |
| 1538 | + | return ms | |
| 1539 | + | } | |
| 1540 | + | return mi.MessageOf(x) | |
| 1541 | + | } | |
| 1542 | + | ||
| 1543 | + | // Deprecated: Use ResolveResponse.ProtoReflect.Descriptor instead. | |
| 1544 | + | func (*ResolveResponse) Descriptor() ([]byte, []int) { | |
| 1545 | + | return file_exec_proto_rawDescGZIP(), []int{15} | |
| 1546 | + | } | |
| 1547 | + | ||
| 1548 | + | func (x *ResolveResponse) GetId() int64 { | |
| 1549 | + | if x != nil { | |
| 1550 | + | return x.Id | |
| 1551 | + | } | |
| 1552 | + | return 0 | |
| 1553 | + | } | |
| 1554 | + | ||
| 1555 | + | func (x *ResolveResponse) GetError() string { | |
| 1556 | + | if x != nil { | |
| 1557 | + | return x.Error | |
| 1558 | + | } | |
| 1559 | + | return "" | |
| 1560 | + | } | |
| 1561 | + | ||
| 1562 | + | func (x *ResolveResponse) GetActions() []*ResolvedAction { | |
| 1563 | + | if x != nil { | |
| 1564 | + | return x.Actions | |
| 1565 | + | } | |
| 1566 | + | return nil | |
| 1567 | + | } | |
| 1568 | + | ||
| 1569 | + | // ResolvedAction mirrors ermite.ResolvedAction (the token is a secret: the | |
| 1570 | + | // executor masks it). | |
| 1571 | + | type ResolvedAction struct { | |
| 1572 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 1573 | + | Ref string `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` | |
| 1574 | + | NameWithOwner string `protobuf:"bytes,2,opt,name=name_with_owner,json=nameWithOwner,proto3" json:"name_with_owner,omitempty"` | |
| 1575 | + | Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` | |
| 1576 | + | ResolvedSha string `protobuf:"bytes,4,opt,name=resolved_sha,json=resolvedSha,proto3" json:"resolved_sha,omitempty"` | |
| 1577 | + | TarballUrl string `protobuf:"bytes,5,opt,name=tarball_url,json=tarballUrl,proto3" json:"tarball_url,omitempty"` | |
| 1578 | + | Token string `protobuf:"bytes,6,opt,name=token,proto3" json:"token,omitempty"` | |
| 1579 | + | Source string `protobuf:"bytes,7,opt,name=source,proto3" json:"source,omitempty"` | |
| 1580 | + | Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"` | |
| 1581 | + | // auth_origin (scheme://host[:port], as the sandbox reaches it) is the only | |
| 1582 | + | // origin the token may be sent to; empty means the token is never sent. The | |
| 1583 | + | // host sets it for archives served by the ermite server, from its own | |
| 1584 | + | // configuration, never from the URL alone. | |
| 1585 | + | AuthOrigin string `protobuf:"bytes,9,opt,name=auth_origin,json=authOrigin,proto3" json:"auth_origin,omitempty"` | |
| 1586 | + | unknownFields protoimpl.UnknownFields | |
| 1587 | + | sizeCache protoimpl.SizeCache | |
| 1588 | + | } | |
| 1589 | + | ||
| 1590 | + | func (x *ResolvedAction) Reset() { | |
| 1591 | + | *x = ResolvedAction{} | |
| 1592 | + | mi := &file_exec_proto_msgTypes[16] | |
| 1593 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1594 | + | ms.StoreMessageInfo(mi) | |
| 1595 | + | } | |
| 1596 | + | ||
| 1597 | + | func (x *ResolvedAction) String() string { | |
| 1598 | + | return protoimpl.X.MessageStringOf(x) | |
| 1599 | + | } | |
| 1600 | + | ||
| 1601 | + | func (*ResolvedAction) ProtoMessage() {} | |
| 1602 | + | ||
| 1603 | + | func (x *ResolvedAction) ProtoReflect() protoreflect.Message { | |
| 1604 | + | mi := &file_exec_proto_msgTypes[16] | |
| 1605 | + | if x != nil { | |
| 1606 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1607 | + | if ms.LoadMessageInfo() == nil { | |
| 1608 | + | ms.StoreMessageInfo(mi) | |
| 1609 | + | } | |
| 1610 | + | return ms | |
| 1611 | + | } | |
| 1612 | + | return mi.MessageOf(x) | |
| 1613 | + | } | |
| 1614 | + | ||
| 1615 | + | // Deprecated: Use ResolvedAction.ProtoReflect.Descriptor instead. | |
| 1616 | + | func (*ResolvedAction) Descriptor() ([]byte, []int) { | |
| 1617 | + | return file_exec_proto_rawDescGZIP(), []int{16} | |
| 1618 | + | } | |
| 1619 | + | ||
| 1620 | + | func (x *ResolvedAction) GetRef() string { | |
| 1621 | + | if x != nil { | |
| 1622 | + | return x.Ref | |
| 1623 | + | } | |
| 1624 | + | return "" | |
| 1625 | + | } | |
| 1626 | + | ||
| 1627 | + | func (x *ResolvedAction) GetNameWithOwner() string { | |
| 1628 | + | if x != nil { | |
| 1629 | + | return x.NameWithOwner | |
| 1630 | + | } | |
| 1631 | + | return "" | |
| 1632 | + | } | |
| 1633 | + | ||
| 1634 | + | func (x *ResolvedAction) GetPath() string { | |
| 1635 | + | if x != nil { | |
| 1636 | + | return x.Path | |
| 1637 | + | } | |
| 1638 | + | return "" | |
| 1639 | + | } | |
| 1640 | + | ||
| 1641 | + | func (x *ResolvedAction) GetResolvedSha() string { | |
| 1642 | + | if x != nil { | |
| 1643 | + | return x.ResolvedSha | |
| 1644 | + | } | |
| 1645 | + | return "" | |
| 1646 | + | } | |
| 1647 | + | ||
| 1648 | + | func (x *ResolvedAction) GetTarballUrl() string { | |
| 1649 | + | if x != nil { | |
| 1650 | + | return x.TarballUrl | |
| 1651 | + | } | |
| 1652 | + | return "" | |
| 1653 | + | } | |
| 1654 | + | ||
| 1655 | + | func (x *ResolvedAction) GetToken() string { | |
| 1656 | + | if x != nil { | |
| 1657 | + | return x.Token | |
| 1658 | + | } | |
| 1659 | + | return "" | |
| 1660 | + | } | |
| 1661 | + | ||
| 1662 | + | func (x *ResolvedAction) GetSource() string { | |
| 1663 | + | if x != nil { | |
| 1664 | + | return x.Source | |
| 1665 | + | } | |
| 1666 | + | return "" | |
| 1667 | + | } | |
| 1668 | + | ||
| 1669 | + | func (x *ResolvedAction) GetError() string { | |
| 1670 | + | if x != nil { | |
| 1671 | + | return x.Error | |
| 1672 | + | } | |
| 1673 | + | return "" | |
| 1674 | + | } | |
| 1675 | + | ||
| 1676 | + | func (x *ResolvedAction) GetAuthOrigin() string { | |
| 1677 | + | if x != nil { | |
| 1678 | + | return x.AuthOrigin | |
| 1679 | + | } | |
| 1680 | + | return "" | |
| 1681 | + | } | |
| 1682 | + | ||
| 1419 | 1683 | var File_exec_proto protoreflect.FileDescriptor | |
| 1420 | 1684 | ||
| 1421 | 1685 | const file_exec_proto_rawDesc = "" + | |
| 1422 | 1686 | "\n" + | |
| 1423 | 1687 | "\n" + | |
| 1424 | - | "exec.proto\x12\x15ermite.runner.exec.v1\"\xc3\x05\n" + | |
| 1688 | + | "exec.proto\x12\x15ermite.runner.exec.v1\"\xea\x06\n" + | |
| 1425 | 1689 | "\x05Frame\x124\n" + | |
| 1426 | 1690 | "\x05hello\x18\x01 \x01(\v2\x1c.ermite.runner.exec.v1.HelloH\x00R\x05hello\x12.\n" + | |
| 1427 | 1691 | "\x03run\x18\x02 \x01(\v2\x1a.ermite.runner.exec.v1.RunH\x00R\x03run\x127\n" + | |
| @@ -1435,7 +1699,9 @@ const file_exec_proto_rawDesc = "" + | |||
| 1435 | 1699 | "job_result\x18\t \x01(\v2 .ermite.runner.exec.v1.JobResultH\x00R\tjobResult\x12S\n" + | |
| 1436 | 1700 | "\x10step_annotations\x18\n" + | |
| 1437 | 1701 | " \x01(\v2&.ermite.runner.exec.v1.StepAnnotationsH\x00R\x0fstepAnnotations\x12G\n" + | |
| 1438 | - | "\fstep_summary\x18\v \x01(\v2\".ermite.runner.exec.v1.StepSummaryH\x00R\vstepSummaryB\x06\n" + | |
| 1702 | + | "\fstep_summary\x18\v \x01(\v2\".ermite.runner.exec.v1.StepSummaryH\x00R\vstepSummary\x12P\n" + | |
| 1703 | + | "\x0fresolve_request\x18\f \x01(\v2%.ermite.runner.exec.v1.ResolveRequestH\x00R\x0eresolveRequest\x12S\n" + | |
| 1704 | + | "\x10resolve_response\x18\r \x01(\v2&.ermite.runner.exec.v1.ResolveResponseH\x00R\x0fresolveResponseB\x06\n" + | |
| 1439 | 1705 | "\x04body\"W\n" + | |
| 1440 | 1706 | "\x05Hello\x12\x18\n" + | |
| 1441 | 1707 | "\aversion\x18\x01 \x01(\tR\aversion\x12\x18\n" + | |
| @@ -1520,7 +1786,26 @@ const file_exec_proto_rawDesc = "" + | |||
| 1520 | 1786 | "\x0frunner_shutdown\x18\x04 \x01(\bR\x0erunnerShutdown\x1a:\n" + | |
| 1521 | 1787 | "\fOutputsEntry\x12\x10\n" + | |
| 1522 | 1788 | "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + | |
| 1523 | - | "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01*Y\n" + | |
| 1789 | + | "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"4\n" + | |
| 1790 | + | "\x0eResolveRequest\x12\x0e\n" + | |
| 1791 | + | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" + | |
| 1792 | + | "\x04uses\x18\x02 \x03(\tR\x04uses\"x\n" + | |
| 1793 | + | "\x0fResolveResponse\x12\x0e\n" + | |
| 1794 | + | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x14\n" + | |
| 1795 | + | "\x05error\x18\x02 \x01(\tR\x05error\x12?\n" + | |
| 1796 | + | "\aactions\x18\x03 \x03(\v2%.ermite.runner.exec.v1.ResolvedActionR\aactions\"\x87\x02\n" + | |
| 1797 | + | "\x0eResolvedAction\x12\x10\n" + | |
| 1798 | + | "\x03ref\x18\x01 \x01(\tR\x03ref\x12&\n" + | |
| 1799 | + | "\x0fname_with_owner\x18\x02 \x01(\tR\rnameWithOwner\x12\x12\n" + | |
| 1800 | + | "\x04path\x18\x03 \x01(\tR\x04path\x12!\n" + | |
| 1801 | + | "\fresolved_sha\x18\x04 \x01(\tR\vresolvedSha\x12\x1f\n" + | |
| 1802 | + | "\vtarball_url\x18\x05 \x01(\tR\n" + | |
| 1803 | + | "tarballUrl\x12\x14\n" + | |
| 1804 | + | "\x05token\x18\x06 \x01(\tR\x05token\x12\x16\n" + | |
| 1805 | + | "\x06source\x18\a \x01(\tR\x06source\x12\x14\n" + | |
| 1806 | + | "\x05error\x18\b \x01(\tR\x05error\x12\x1f\n" + | |
| 1807 | + | "\vauth_origin\x18\t \x01(\tR\n" + | |
| 1808 | + | "authOrigin*Y\n" + | |
| 1524 | 1809 | "\n" + | |
| 1525 | 1810 | "StreamKind\x12\x1b\n" + | |
| 1526 | 1811 | "\x17STREAM_KIND_UNSPECIFIED\x10\x00\x12\x16\n" + | |
| @@ -1558,7 +1843,7 @@ func file_exec_proto_rawDescGZIP() []byte { | |||
| 1558 | 1843 | } | |
| 1559 | 1844 | ||
| 1560 | 1845 | var file_exec_proto_enumTypes = make([]protoimpl.EnumInfo, 4) | |
| 1561 | - | var file_exec_proto_msgTypes = make([]protoimpl.MessageInfo, 16) | |
| 1846 | + | var file_exec_proto_msgTypes = make([]protoimpl.MessageInfo, 19) | |
| 1562 | 1847 | var file_exec_proto_goTypes = []any{ | |
| 1563 | 1848 | (StreamKind)(0), // 0: ermite.runner.exec.v1.StreamKind | |
| 1564 | 1849 | (CancelKind)(0), // 1: ermite.runner.exec.v1.CancelKind | |
| @@ -1578,8 +1863,11 @@ var file_exec_proto_goTypes = []any{ | |||
| 1578 | 1863 | (*StepSummary)(nil), // 15: ermite.runner.exec.v1.StepSummary | |
| 1579 | 1864 | (*Log)(nil), // 16: ermite.runner.exec.v1.Log | |
| 1580 | 1865 | (*JobResult)(nil), // 17: ermite.runner.exec.v1.JobResult | |
| 1581 | - | nil, // 18: ermite.runner.exec.v1.Run.EnvEntry | |
| 1582 | - | nil, // 19: ermite.runner.exec.v1.JobResult.OutputsEntry | |
| 1866 | + | (*ResolveRequest)(nil), // 18: ermite.runner.exec.v1.ResolveRequest | |
| 1867 | + | (*ResolveResponse)(nil), // 19: ermite.runner.exec.v1.ResolveResponse | |
| 1868 | + | (*ResolvedAction)(nil), // 20: ermite.runner.exec.v1.ResolvedAction | |
| 1869 | + | nil, // 21: ermite.runner.exec.v1.Run.EnvEntry | |
| 1870 | + | nil, // 22: ermite.runner.exec.v1.JobResult.OutputsEntry | |
| 1583 | 1871 | } | |
| 1584 | 1872 | var file_exec_proto_depIdxs = []int32{ | |
| 1585 | 1873 | 5, // 0: ermite.runner.exec.v1.Frame.hello:type_name -> ermite.runner.exec.v1.Hello | |
| @@ -1593,21 +1881,24 @@ var file_exec_proto_depIdxs = []int32{ | |||
| 1593 | 1881 | 17, // 8: ermite.runner.exec.v1.Frame.job_result:type_name -> ermite.runner.exec.v1.JobResult | |
| 1594 | 1882 | 14, // 9: ermite.runner.exec.v1.Frame.step_annotations:type_name -> ermite.runner.exec.v1.StepAnnotations | |
| 1595 | 1883 | 15, // 10: ermite.runner.exec.v1.Frame.step_summary:type_name -> ermite.runner.exec.v1.StepSummary | |
| 1596 | - | 18, // 11: ermite.runner.exec.v1.Run.env:type_name -> ermite.runner.exec.v1.Run.EnvEntry | |
| 1597 | - | 7, // 12: ermite.runner.exec.v1.Run.job:type_name -> ermite.runner.exec.v1.JobSpec | |
| 1598 | - | 0, // 13: ermite.runner.exec.v1.Output.stream:type_name -> ermite.runner.exec.v1.StreamKind | |
| 1599 | - | 1, // 14: ermite.runner.exec.v1.Cancel.kind:type_name -> ermite.runner.exec.v1.CancelKind | |
| 1600 | - | 3, // 15: ermite.runner.exec.v1.StepCompleted.conclusion:type_name -> ermite.runner.exec.v1.StepConclusion | |
| 1601 | - | 3, // 16: ermite.runner.exec.v1.StepCompleted.outcome:type_name -> ermite.runner.exec.v1.StepConclusion | |
| 1602 | - | 2, // 17: ermite.runner.exec.v1.Annotation.level:type_name -> ermite.runner.exec.v1.AnnotationLevel | |
| 1603 | - | 13, // 18: ermite.runner.exec.v1.StepAnnotations.annotations:type_name -> ermite.runner.exec.v1.Annotation | |
| 1604 | - | 3, // 19: ermite.runner.exec.v1.JobResult.conclusion:type_name -> ermite.runner.exec.v1.StepConclusion | |
| 1605 | - | 19, // 20: ermite.runner.exec.v1.JobResult.outputs:type_name -> ermite.runner.exec.v1.JobResult.OutputsEntry | |
| 1606 | - | 21, // [21:21] is the sub-list for method output_type | |
| 1607 | - | 21, // [21:21] is the sub-list for method input_type | |
| 1608 | - | 21, // [21:21] is the sub-list for extension type_name | |
| 1609 | - | 21, // [21:21] is the sub-list for extension extendee | |
| 1610 | - | 0, // [0:21] is the sub-list for field type_name | |
| 1884 | + | 18, // 11: ermite.runner.exec.v1.Frame.resolve_request:type_name -> ermite.runner.exec.v1.ResolveRequest | |
| 1885 | + | 19, // 12: ermite.runner.exec.v1.Frame.resolve_response:type_name -> ermite.runner.exec.v1.ResolveResponse | |
| 1886 | + | 21, // 13: ermite.runner.exec.v1.Run.env:type_name -> ermite.runner.exec.v1.Run.EnvEntry | |
| 1887 | + | 7, // 14: ermite.runner.exec.v1.Run.job:type_name -> ermite.runner.exec.v1.JobSpec | |
| 1888 | + | 0, // 15: ermite.runner.exec.v1.Output.stream:type_name -> ermite.runner.exec.v1.StreamKind | |
| 1889 | + | 1, // 16: ermite.runner.exec.v1.Cancel.kind:type_name -> ermite.runner.exec.v1.CancelKind | |
| 1890 | + | 3, // 17: ermite.runner.exec.v1.StepCompleted.conclusion:type_name -> ermite.runner.exec.v1.StepConclusion | |
| 1891 | + | 3, // 18: ermite.runner.exec.v1.StepCompleted.outcome:type_name -> ermite.runner.exec.v1.StepConclusion | |
| 1892 | + | 2, // 19: ermite.runner.exec.v1.Annotation.level:type_name -> ermite.runner.exec.v1.AnnotationLevel | |
| 1893 | + | 13, // 20: ermite.runner.exec.v1.StepAnnotations.annotations:type_name -> ermite.runner.exec.v1.Annotation | |
| 1894 | + | 3, // 21: ermite.runner.exec.v1.JobResult.conclusion:type_name -> ermite.runner.exec.v1.StepConclusion | |
| 1895 | + | 22, // 22: ermite.runner.exec.v1.JobResult.outputs:type_name -> ermite.runner.exec.v1.JobResult.OutputsEntry | |
| 1896 | + | 20, // 23: ermite.runner.exec.v1.ResolveResponse.actions:type_name -> ermite.runner.exec.v1.ResolvedAction | |
| 1897 | + | 24, // [24:24] is the sub-list for method output_type | |
| 1898 | + | 24, // [24:24] is the sub-list for method input_type | |
| 1899 | + | 24, // [24:24] is the sub-list for extension type_name | |
| 1900 | + | 24, // [24:24] is the sub-list for extension extendee | |
| 1901 | + | 0, // [0:24] is the sub-list for field type_name | |
| 1611 | 1902 | } | |
| 1612 | 1903 | ||
| 1613 | 1904 | func init() { file_exec_proto_init() } | |
| @@ -1627,6 +1918,8 @@ func file_exec_proto_init() { | |||
| 1627 | 1918 | (*Frame_JobResult)(nil), | |
| 1628 | 1919 | (*Frame_StepAnnotations)(nil), | |
| 1629 | 1920 | (*Frame_StepSummary)(nil), | |
| 1921 | + | (*Frame_ResolveRequest)(nil), | |
| 1922 | + | (*Frame_ResolveResponse)(nil), | |
| 1630 | 1923 | } | |
| 1631 | 1924 | type x struct{} | |
| 1632 | 1925 | out := protoimpl.TypeBuilder{ | |
| @@ -1634,7 +1927,7 @@ func file_exec_proto_init() { | |||
| 1634 | 1927 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 1635 | 1928 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_exec_proto_rawDesc), len(file_exec_proto_rawDesc)), | |
| 1636 | 1929 | NumEnums: 4, | |
| 1637 | - | NumMessages: 16, | |
| 1930 | + | NumMessages: 19, | |
| 1638 | 1931 | NumExtensions: 0, | |
| 1639 | 1932 | NumServices: 0, | |
| 1640 | 1933 | }, | |
M internal/execstream/frame.go +83 -0
| @@ -13,11 +13,13 @@ package execstream | |||
| 13 | 13 | ||
| 14 | 14 | import ( | |
| 15 | 15 | "bufio" | |
| 16 | + | "context" | |
| 16 | 17 | "encoding/binary" | |
| 17 | 18 | "errors" | |
| 18 | 19 | "fmt" | |
| 19 | 20 | "io" | |
| 20 | 21 | "sync" | |
| 22 | + | "time" | |
| 21 | 23 | ||
| 22 | 24 | "google.golang.org/protobuf/proto" | |
| 23 | 25 | ||
| @@ -105,6 +107,45 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu | |||
| 105 | 107 | return DriveJob(rw, run, cancel, JobHandler{Output: onOutput}) | |
| 106 | 108 | } | |
| 107 | 109 | ||
| 110 | + | // resolveJoinGrace is how long DriveJob waits for the resolution worker to | |
| 111 | + | // finish once the session is over before it interrupts a write that is stuck | |
| 112 | + | // (a guest that stopped reading): the write deadline is set to now, and the | |
| 113 | + | // stream is closed if it cannot be interrupted otherwise. The session is over at | |
| 114 | + | // that point, the caller closes the stream anyway. | |
| 115 | + | var resolveJoinGrace = 2 * time.Second | |
| 116 | + | ||
| 117 | + | // joinResolver waits for the worker. A worker in a Send that cannot finish | |
| 118 | + | // because the peer does not read would block DriveJob for ever, so the write is | |
| 119 | + | // interrupted after the grace period. | |
| 120 | + | func joinResolver(rw io.ReadWriter, workers *sync.WaitGroup) { | |
| 121 | + | finished := make(chan struct{}) | |
| 122 | + | go func() { workers.Wait(); close(finished) }() | |
| 123 | + | select { | |
| 124 | + | case <-finished: | |
| 125 | + | return | |
| 126 | + | case <-time.After(resolveJoinGrace): | |
| 127 | + | } | |
| 128 | + | if d, ok := rw.(interface{ SetWriteDeadline(time.Time) error }); ok { | |
| 129 | + | _ = d.SetWriteDeadline(time.Now()) | |
| 130 | + | } | |
| 131 | + | select { | |
| 132 | + | case <-finished: | |
| 133 | + | return | |
| 134 | + | case <-time.After(resolveJoinGrace): | |
| 135 | + | } | |
| 136 | + | if cl, ok := rw.(io.Closer); ok { | |
| 137 | + | _ = cl.Close() | |
| 138 | + | <-finished | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // resolveQueue is how many ResolveRequest frames may wait for the worker. | |
| 143 | + | const resolveQueue = 2 | |
| 144 | + | ||
| 145 | + | func resolveFrame(r *execpb.ResolveResponse) *execpb.Frame { | |
| 146 | + | return &execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: r}} | |
| 147 | + | } | |
| 148 | + | ||
| 108 | 149 | // JobHandler receives the frames of a session. Nil functions ignore their | |
| 109 | 150 | // frames. The functions are called from the goroutine of DriveJob, in the | |
| 110 | 151 | // order of the stream. | |
| @@ -115,6 +156,13 @@ type JobHandler struct { | |||
| 115 | 156 | StepCompleted func(*execpb.StepCompleted) | |
| 116 | 157 | Log func(*execpb.Log) | |
| 117 | 158 | JobResult func(*execpb.JobResult) | |
| 159 | + | // Resolve answers the executor's ResolveRequest frames, one at a time and in | |
| 160 | + | // order, from a single worker (the call goes to the server); the answer is | |
| 161 | + | // sent as a ResolveResponse frame carrying the id of the request. ctx ends | |
| 162 | + | // with the session: a call still running is cancelled and DriveJob waits for | |
| 163 | + | // it before it returns. At most resolveQueue requests wait; the next ones are | |
| 164 | + | // answered with an error at once, without a call. | |
| 165 | + | Resolve func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse | |
| 118 | 166 | // StepAnnotations and StepSummary come before the StepCompleted of their | |
| 119 | 167 | // step. | |
| 120 | 168 | StepAnnotations func(*execpb.StepAnnotations) | |
| @@ -142,6 +190,30 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h | |||
| 142 | 190 | ||
| 143 | 191 | done := make(chan struct{}) | |
| 144 | 192 | defer close(done) | |
| 193 | + | ||
| 194 | + | // One worker answers the resolutions; it ends, and is waited for, with the | |
| 195 | + | // session. | |
| 196 | + | resolveCh := make(chan *execpb.ResolveRequest, resolveQueue) | |
| 197 | + | rctx, rcancel := context.WithCancel(context.Background()) | |
| 198 | + | var workers sync.WaitGroup | |
| 199 | + | workers.Add(1) | |
| 200 | + | go func() { | |
| 201 | + | defer workers.Done() | |
| 202 | + | for { | |
| 203 | + | select { | |
| 204 | + | case req := <-resolveCh: | |
| 205 | + | resp := h.Resolve(rctx, req) | |
| 206 | + | resp.Id = req.Id | |
| 207 | + | if rctx.Err() != nil { | |
| 208 | + | return // the session is over: nobody reads the answer | |
| 209 | + | } | |
| 210 | + | _ = c.Send(resolveFrame(resp)) | |
| 211 | + | case <-rctx.Done(): | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | }() | |
| 216 | + | defer func() { rcancel(); joinResolver(rw, &workers) }() | |
| 145 | 217 | if cancel != nil { | |
| 146 | 218 | go func() { | |
| 147 | 219 | select { | |
| @@ -183,6 +255,17 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h | |||
| 183 | 255 | if h.JobResult != nil { | |
| 184 | 256 | h.JobResult(b.JobResult) | |
| 185 | 257 | } | |
| 258 | + | case *execpb.Frame_ResolveRequest: | |
| 259 | + | req := b.ResolveRequest | |
| 260 | + | if h.Resolve == nil { | |
| 261 | + | _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"})) | |
| 262 | + | break | |
| 263 | + | } | |
| 264 | + | select { | |
| 265 | + | case resolveCh <- req: | |
| 266 | + | default: | |
| 267 | + | _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "too many action resolutions in flight"})) | |
| 268 | + | } | |
| 186 | 269 | case *execpb.Frame_StepAnnotations: | |
| 187 | 270 | if h.StepAnnotations != nil { | |
| 188 | 271 | h.StepAnnotations(b.StepAnnotations) | |
M internal/execstream/frame_test.go +156 -0
| @@ -4,11 +4,13 @@ package execstream | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | + | "context" | |
| 7 | 8 | "encoding/binary" | |
| 8 | 9 | "errors" | |
| 9 | 10 | "io" | |
| 10 | 11 | "net" | |
| 11 | 12 | "strings" | |
| 13 | + | "sync/atomic" | |
| 12 | 14 | "testing" | |
| 13 | 15 | "testing/iotest" | |
| 14 | 16 | "time" | |
| @@ -172,3 +174,157 @@ func TestDriveJobDeliversJobFrames(t *testing.T) { | |||
| 172 | 174 | t.Fatalf("%v", got) | |
| 173 | 175 | } | |
| 174 | 176 | } | |
| 177 | + | ||
| 178 | + | // GO-003: a flood of ResolveRequest frames is answered by one worker, the ones | |
| 179 | + | // beyond the queue with an error and no call, and DriveJob does not return | |
| 180 | + | // before the worker is done (a call in flight is cancelled). | |
| 181 | + | func TestDriveJobResolveBurst(t *testing.T) { | |
| 182 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 183 | + | if err != nil { | |
| 184 | + | t.Skip(err) | |
| 185 | + | } | |
| 186 | + | defer func() { _ = ln.Close() }() | |
| 187 | + | g, err := net.Dial("tcp", ln.Addr().String()) | |
| 188 | + | if err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | h, err := ln.Accept() | |
| 192 | + | if err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | defer func() { _ = g.Close() }() | |
| 196 | + | gc := NewConn(g) | |
| 197 | + | if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}); err != nil { | |
| 198 | + | t.Fatal(err) | |
| 199 | + | } | |
| 200 | + | var running, maxRunning, started atomic.Int32 | |
| 201 | + | release := make(chan struct{}) | |
| 202 | + | var exited atomic.Bool | |
| 203 | + | done := make(chan struct{}) | |
| 204 | + | go func() { | |
| 205 | + | defer close(done) | |
| 206 | + | _, _, _ = DriveJob(h, &execpb.Run{}, nil, JobHandler{Resolve: func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 207 | + | n := running.Add(1) | |
| 208 | + | defer running.Add(-1) | |
| 209 | + | for { | |
| 210 | + | m := maxRunning.Load() | |
| 211 | + | if n <= m || maxRunning.CompareAndSwap(m, n) { | |
| 212 | + | break | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | started.Add(1) | |
| 216 | + | select { | |
| 217 | + | case <-release: | |
| 218 | + | case <-ctx.Done(): | |
| 219 | + | if exited.Load() { | |
| 220 | + | t.Error("the worker outlived DriveJob") | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | return &execpb.ResolveResponse{} | |
| 224 | + | }}) | |
| 225 | + | exited.Store(true) | |
| 226 | + | }() | |
| 227 | + | // 50 requests at once while the first call is held. | |
| 228 | + | for i := int64(1); i <= 50; i++ { | |
| 229 | + | if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: &execpb.ResolveRequest{Id: i, Uses: []string{"a/b@v1"}}}}); err != nil { | |
| 230 | + | t.Fatal(err) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | // Collect the refusals, which are immediate: until the stream is quiet. The | |
| 234 | + | // worker may or may not have taken the first request before the queue filled, | |
| 235 | + | // so the queue and the running call hold 2 or 3 of the 50. | |
| 236 | + | refused := 0 | |
| 237 | + | for { | |
| 238 | + | _ = g.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) | |
| 239 | + | f, err := gc.Recv() | |
| 240 | + | if err != nil { | |
| 241 | + | break | |
| 242 | + | } | |
| 243 | + | if r := f.GetResolveResponse(); r != nil && r.Error != "" { | |
| 244 | + | refused++ | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | _ = g.SetReadDeadline(time.Time{}) | |
| 248 | + | if refused < 50-1-resolveQueue || refused > 50-resolveQueue { | |
| 249 | + | t.Fatalf("%d refused of 50, want %d or %d", refused, 50-1-resolveQueue, 50-resolveQueue) | |
| 250 | + | } | |
| 251 | + | if maxRunning.Load() != 1 || started.Load() != 1 { | |
| 252 | + | t.Errorf("running %d, started %d: resolutions are serial", maxRunning.Load(), started.Load()) | |
| 253 | + | } | |
| 254 | + | // End the session with a call still in flight: it is cancelled and joined. | |
| 255 | + | _ = g.Close() | |
| 256 | + | select { | |
| 257 | + | case <-done: | |
| 258 | + | case <-time.After(5 * time.Second): | |
| 259 | + | t.Fatal("DriveJob did not return") | |
| 260 | + | } | |
| 261 | + | close(release) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // GO-009: the resolution worker is in a Send that cannot finish because the | |
| 265 | + | // guest stopped reading, and the guest ends the session with Exit. DriveJob | |
| 266 | + | // must return, not wait for the write for ever. | |
| 267 | + | func TestDriveJobReturnsWhenTheResolverWriteIsStuck(t *testing.T) { | |
| 268 | + | old := resolveJoinGrace | |
| 269 | + | resolveJoinGrace = 50 * time.Millisecond | |
| 270 | + | defer func() { resolveJoinGrace = old }() | |
| 271 | + | hostSide, guestSide := net.Pipe() // synchronous: a write needs a reader | |
| 272 | + | defer func() { _ = guestSide.Close() }() | |
| 273 | + | gc := NewConn(guestSide) | |
| 274 | + | go func() { | |
| 275 | + | _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}) | |
| 276 | + | if _, err := gc.Recv(); err != nil { // the Run frame | |
| 277 | + | return | |
| 278 | + | } | |
| 279 | + | _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: &execpb.ResolveRequest{Id: 1, Uses: []string{"a/b@v1"}}}}) | |
| 280 | + | time.Sleep(100 * time.Millisecond) // the answer is being written: nobody reads it | |
| 281 | + | _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}}) | |
| 282 | + | }() | |
| 283 | + | done := make(chan error, 1) | |
| 284 | + | go func() { | |
| 285 | + | _, exit, err := DriveJob(hostSide, &execpb.Run{}, nil, JobHandler{Resolve: func(context.Context, *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 286 | + | return &execpb.ResolveResponse{} | |
| 287 | + | }}) | |
| 288 | + | if err == nil && exit == nil { | |
| 289 | + | err = errors.New("no exit") | |
| 290 | + | } | |
| 291 | + | done <- err | |
| 292 | + | }() | |
| 293 | + | select { | |
| 294 | + | case err := <-done: | |
| 295 | + | if err != nil { | |
| 296 | + | t.Fatal(err) | |
| 297 | + | } | |
| 298 | + | case <-time.After(10 * time.Second): | |
| 299 | + | t.Fatal("DriveJob is blocked by a write nobody reads") | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // And a worker that has an answer when the session ends does not write it. | |
| 304 | + | func TestDriveJobDoesNotSendAfterTheSession(t *testing.T) { | |
| 305 | + | hostSide, guestSide := net.Pipe() | |
| 306 | + | defer func() { _ = guestSide.Close() }() | |
| 307 | + | gc := NewConn(guestSide) | |
| 308 | + | release := make(chan struct{}) | |
| 309 | + | go func() { | |
| 310 | + | _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}) | |
| 311 | + | _, _ = gc.Recv() | |
| 312 | + | _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: &execpb.ResolveRequest{Id: 1, Uses: []string{"a/b@v1"}}}}) | |
| 313 | + | _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}}) | |
| 314 | + | close(release) | |
| 315 | + | }() | |
| 316 | + | done := make(chan struct{}) | |
| 317 | + | go func() { | |
| 318 | + | defer close(done) | |
| 319 | + | _, _, _ = DriveJob(hostSide, &execpb.Run{}, nil, JobHandler{Resolve: func(ctx context.Context, _ *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 320 | + | <-ctx.Done() // returns only when the session ends | |
| 321 | + | return &execpb.ResolveResponse{} | |
| 322 | + | }}) | |
| 323 | + | }() | |
| 324 | + | select { | |
| 325 | + | case <-done: | |
| 326 | + | case <-time.After(10 * time.Second): | |
| 327 | + | t.Fatal("DriveJob did not return") | |
| 328 | + | } | |
| 329 | + | <-release | |
| 330 | + | } | |
A internal/executor/actions_cancel_test.go +190 -0
| @@ -0,0 +1,190 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | "testing" | |
| 14 | + | "time" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // GO-006: the image setup of a local composite action (pull, retry sleep) stops | |
| 18 | + | // with the step. | |
| 19 | + | func TestLocalCompositeSetupIsCancellable(t *testing.T) { | |
| 20 | + | needTools(t, "bash") | |
| 21 | + | dir := t.TempDir() | |
| 22 | + | pulls := filepath.Join(dir, "pulls") | |
| 23 | + | script := "#!/bin/sh\necho x >> " + pulls + "\necho ready\nexit 1\n" | |
| 24 | + | if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | c := NewCanceller() | |
| 28 | + | started := make(chan struct{}) | |
| 29 | + | go func() { | |
| 30 | + | <-started | |
| 31 | + | c.Cancel(CancelUser) | |
| 32 | + | }() | |
| 33 | + | home := t.TempDir() | |
| 34 | + | layout := NewLayout(home, "hello", home+"/tc") | |
| 35 | + | writeFiles(t, layout.Workspace, files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: docker://alpine:3\n"}) | |
| 36 | + | rec := newRecorder() | |
| 37 | + | rep := &signalReporter{recorder: rec, started: started, match: "ready"} | |
| 38 | + | done := make(chan Result, 1) | |
| 39 | + | go func() { | |
| 40 | + | done <- Run(c, Config{ | |
| 41 | + | WorkflowYAML: []byte(wfHead + " steps:\n - uses: ./c\n - if: always()\n run: echo cleanup-ran\n"), | |
| 42 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep, | |
| 43 | + | BaseEnv: []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home}, | |
| 44 | + | TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 500 * time.Millisecond, | |
| 45 | + | Actions: ActionsConfig{DockerPath: filepath.Join(dir, "docker"), Backoff: func() time.Duration { return time.Hour }}, | |
| 46 | + | }) | |
| 47 | + | }() | |
| 48 | + | select { | |
| 49 | + | case res := <-done: | |
| 50 | + | if res.Conclusion != ConclusionCancelled { | |
| 51 | + | t.Fatalf("%+v", res) | |
| 52 | + | } | |
| 53 | + | case <-time.After(20 * time.Second): | |
| 54 | + | t.Fatal("the retry sleep of the pull ignored the cancellation") | |
| 55 | + | } | |
| 56 | + | if b, _ := os.ReadFile(pulls); strings.Count(string(b), "x") != 1 { | |
| 57 | + | t.Errorf("no retry after the cancellation: %q", b) | |
| 58 | + | } | |
| 59 | + | if got := rec.step(t, "Run ./c").Conclusion; got != ConclusionCancelled { | |
| 60 | + | t.Errorf("step %s", got) | |
| 61 | + | } | |
| 62 | + | if got := rec.step(t, "Run echo cleanup-ran").Conclusion; got != ConclusionSuccess { | |
| 63 | + | t.Errorf("always() step %s", got) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // GO-014: what the timeout or the cancellation of a step breaks while a local | |
| 68 | + | // composite action is prepared (a resolution, a download, an image pull and its | |
| 69 | + | // retry sleep) ends the step as the cancellation of the step does: the timeout | |
| 70 | + | // message and a failed step for timeout-minutes, a cancelled step for a user | |
| 71 | + | // cancel, and no stray "The operation was canceled.". | |
| 72 | + | func TestPreparationInterruptedByTheStepTimeout(t *testing.T) { | |
| 73 | + | skew := -(60*time.Second - 1500*time.Millisecond) | |
| 74 | + | leaf := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"} | |
| 75 | + | docker := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: docker://alpine:3\n"} | |
| 76 | + | hang := make(chan struct{}) | |
| 77 | + | slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 78 | + | select { | |
| 79 | + | case <-r.Context().Done(): | |
| 80 | + | case <-hang: | |
| 81 | + | } | |
| 82 | + | })) | |
| 83 | + | defer slow.Close() | |
| 84 | + | defer close(hang) | |
| 85 | + | failDocker := filepath.Join(t.TempDir(), "docker") | |
| 86 | + | writeFiles(t, filepath.Dir(failDocker), files{"docker": "#!/bin/sh\necho pull failed\nexit 1\n"}) | |
| 87 | + | ||
| 88 | + | for name, tc := range map[string]struct { | |
| 89 | + | tree files | |
| 90 | + | tweak func(*Config) | |
| 91 | + | }{ | |
| 92 | + | "blocked resolver": {leaf, func(c *Config) { | |
| 93 | + | c.Actions.Resolver = func(ctx context.Context, _ []string) ([]ResolvedAction, error) { | |
| 94 | + | <-ctx.Done() | |
| 95 | + | return nil, ctx.Err() | |
| 96 | + | } | |
| 97 | + | }}, | |
| 98 | + | "blocked download": {leaf, func(c *Config) { | |
| 99 | + | c.Actions.Resolver = func(context.Context, []string) ([]ResolvedAction, error) { | |
| 100 | + | return []ResolvedAction{{Ref: "o/leaf@v1", NameWithOwner: "o/leaf", ResolvedSHA: strings.Repeat("a", 40), TarballURL: slow.URL + "/t"}}, nil | |
| 101 | + | } | |
| 102 | + | }}, | |
| 103 | + | "docker pull retry sleep": {docker, func(c *Config) { | |
| 104 | + | c.Actions.DockerPath = failDocker | |
| 105 | + | c.Actions.Backoff = func() time.Duration { return time.Hour } | |
| 106 | + | }}, | |
| 107 | + | } { | |
| 108 | + | t.Run(name, func(t *testing.T) { | |
| 109 | + | start := time.Now() | |
| 110 | + | rr, res := actionResults(t, tc.tree, " - name: comp\n timeout-minutes: 1\n uses: ./c\n - name: after\n if: always()\n run: echo after-ran\n", func(c *Config) { | |
| 111 | + | c.StepTimeoutSkew = skew | |
| 112 | + | tc.tweak(c) | |
| 113 | + | }) | |
| 114 | + | if time.Since(start) > 15*time.Second { | |
| 115 | + | t.Fatalf("the preparation was not interrupted: %v", time.Since(start)) | |
| 116 | + | } | |
| 117 | + | comp := rr.step(t, "comp") | |
| 118 | + | log := logOf(comp) | |
| 119 | + | if res.Conclusion != ConclusionFailure || comp.Conclusion != ConclusionFailure { | |
| 120 | + | t.Fatalf("job %s, step %s\n%s", res.Conclusion, comp.Conclusion, log) | |
| 121 | + | } | |
| 122 | + | if !strings.Contains(log, "##[error]The action 'comp' has timed out after 1 minutes.") { | |
| 123 | + | t.Errorf("no timeout message:\n%s", log) | |
| 124 | + | } | |
| 125 | + | if strings.Contains(log, "The operation was canceled.") { | |
| 126 | + | t.Errorf("a stray cancel text:\n%s", log) | |
| 127 | + | } | |
| 128 | + | if rr.step(t, "after").Conclusion != ConclusionSuccess { | |
| 129 | + | t.Error("the job is not cancelled by a step timeout") | |
| 130 | + | } | |
| 131 | + | }) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestPreparationInterruptedByAUserCancel(t *testing.T) { | |
| 136 | + | needTools(t, "bash") | |
| 137 | + | entered := make(chan struct{}) | |
| 138 | + | var once sync.Once | |
| 139 | + | home := t.TempDir() | |
| 140 | + | layout := NewLayout(home, "hello", home+"/tc") | |
| 141 | + | writeFiles(t, layout.Workspace, files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"}) | |
| 142 | + | c := NewCanceller() | |
| 143 | + | go func() { | |
| 144 | + | <-entered | |
| 145 | + | c.Cancel(CancelUser) | |
| 146 | + | }() | |
| 147 | + | rec := newRecorder() | |
| 148 | + | done := make(chan Result, 1) | |
| 149 | + | go func() { | |
| 150 | + | done <- Run(c, Config{ | |
| 151 | + | WorkflowYAML: []byte(wfHead + " steps:\n - name: comp\n uses: ./c\n - name: cleanup\n if: always()\n run: echo cleanup-ran\n"), | |
| 152 | + | JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rec, | |
| 153 | + | BaseEnv: []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home}, | |
| 154 | + | TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 500 * time.Millisecond, | |
| 155 | + | Actions: ActionsConfig{Resolver: func(ctx context.Context, _ []string) ([]ResolvedAction, error) { | |
| 156 | + | once.Do(func() { close(entered) }) | |
| 157 | + | <-ctx.Done() | |
| 158 | + | return nil, ctx.Err() | |
| 159 | + | }}, | |
| 160 | + | }) | |
| 161 | + | }() | |
| 162 | + | select { | |
| 163 | + | case res := <-done: | |
| 164 | + | if res.Conclusion != ConclusionCancelled { | |
| 165 | + | t.Fatalf("%+v", res) | |
| 166 | + | } | |
| 167 | + | case <-time.After(20 * time.Second): | |
| 168 | + | t.Fatal("the resolution ignored the cancellation") | |
| 169 | + | } | |
| 170 | + | if got := rec.step(t, "comp").Conclusion; got != ConclusionCancelled { | |
| 171 | + | t.Errorf("step %s, want cancelled", got) | |
| 172 | + | } | |
| 173 | + | if got := rec.step(t, "cleanup").Conclusion; got != ConclusionSuccess { | |
| 174 | + | t.Errorf("always() step %s", got) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // A genuine error of the preparation still fails the step. | |
| 179 | + | func TestPreparationErrorStillFailsTheStep(t *testing.T) { | |
| 180 | + | tree := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"} | |
| 181 | + | rr, res := actionResults(t, tree, " - name: comp\n uses: ./c\n", func(c *Config) { | |
| 182 | + | c.Actions.Resolver = func(context.Context, []string) ([]ResolvedAction, error) { | |
| 183 | + | return []ResolvedAction{{Ref: "o/leaf@v1", Error: "Unable to resolve action `o/leaf@v1`, repository not found: `o/leaf`"}}, nil | |
| 184 | + | } | |
| 185 | + | }) | |
| 186 | + | comp := rr.step(t, "comp") | |
| 187 | + | if res.Conclusion != ConclusionFailure || comp.Conclusion != ConclusionFailure || !strings.Contains(logOf(comp), "##[error]Unable to resolve action `o/leaf@v1`, repository not found: `o/leaf`") { | |
| 188 | + | t.Errorf("%s %s\n%s", res.Conclusion, comp.Conclusion, logOf(comp)) | |
| 189 | + | } | |
| 190 | + | } | |
A internal/executor/actions_composite.go +229 -0
| @@ -0,0 +1,229 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite-actions/expr" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // compositeGitHubKeys are the keys of the github context that a composite | |
| 12 | + | // action's steps change: the runner gives them a copy of the context | |
| 13 | + | // (CompositeActionHandler.cs:152-161), the executor restores these afterwards. | |
| 14 | + | func compositeGitHubKeys() []string { | |
| 15 | + | keys := []string{"action_path", "action_status", "action_repository", "action_ref", "event_path"} | |
| 16 | + | for _, c := range fileCommands { | |
| 17 | + | keys = append(keys, c.ContextName) | |
| 18 | + | } | |
| 19 | + | return keys | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // maxLocalDepth is the nesting of composite actions the executor allows. It is | |
| 23 | + | // ermite's guard against a local composite action that uses itself, not a limit | |
| 24 | + | // of GitHub's, whose runner has none for local actions. | |
| 25 | + | const maxLocalDepth = 100 | |
| 26 | + | ||
| 27 | + | // prepareLocalComposite is the first part of ActionRunner.RunAsync for a local | |
| 28 | + | // composite action (ActionRunner.cs:77-96): its steps are prepared when the | |
| 29 | + | // step runs, in the log of the step, and the pulls and builds they need run | |
| 30 | + | // first as embedded steps. | |
| 31 | + | func (j *job) prepareLocalComposite(s *stepRun, def *actionDef, cancel <-chan struct{}) error { | |
| 32 | + | n := s.n | |
| 33 | + | if n.children != nil { | |
| 34 | + | return nil // pre and post stages reuse the nodes of the main stage | |
| 35 | + | } | |
| 36 | + | // Local composite actions have no depth limit on GitHub: the recording of | |
| 37 | + | // ermite-conformance corpus/act-composite-depth has composites of 9, 10 and 12 | |
| 38 | + | // levels, all of which succeed. The runner prepares the steps of a local | |
| 39 | + | // composite when its step runs, with the depth restarting at 1 | |
| 40 | + | // (ActionRunner.cs:77-96), which is why. This port does the same, and the | |
| 41 | + | // resolutions it needs are cached and budgeted by the host (host/resolve.go). | |
| 42 | + | // | |
| 43 | + | // The one guard that is ermite's own, not GitHub's: a local composite that | |
| 44 | + | // uses itself (directly or in a cycle) would recurse until the executor | |
| 45 | + | // crashes, so the nesting of composite actions is capped at maxLocalDepth. | |
| 46 | + | if d := s.depth(); d >= maxLocalDepth { | |
| 47 | + | return ghErrorf("Composite action depth exceeded max depth %d", maxLocalDepth) | |
| 48 | + | } | |
| 49 | + | kids, err := newNodes(def.act.Runs.Steps, n, n.scope) | |
| 50 | + | if err != nil { | |
| 51 | + | return err | |
| 52 | + | } | |
| 53 | + | n.children = kids | |
| 54 | + | n.preSteps, n.postSteps = nil, nil | |
| 55 | + | j.out(s.num, "Prepare all required actions") | |
| 56 | + | if j.actions == nil { | |
| 57 | + | j.actions = newActionState() | |
| 58 | + | } | |
| 59 | + | saved := *j.actions | |
| 60 | + | j.actions.pullOrder, j.actions.buildOrder = nil, nil | |
| 61 | + | j.actions.pullUsers, j.actions.buildUsers = map[string][]*node{}, map[string][]*node{} | |
| 62 | + | // The preparation stops with the step, and only with it: its context ends when | |
| 63 | + | // the cancel channel of the step closes (job cancellation for a step that does | |
| 64 | + | // not run on always(), timeout-minutes). A step that keeps running after the | |
| 65 | + | // job was cancelled keeps the means to prepare its composite. | |
| 66 | + | ctx, stop := context.WithCancel(context.Background()) | |
| 67 | + | defer stop() | |
| 68 | + | if cancel != nil { | |
| 69 | + | go func() { | |
| 70 | + | select { | |
| 71 | + | case <-cancel: | |
| 72 | + | stop() | |
| 73 | + | case <-ctx.Done(): | |
| 74 | + | } | |
| 75 | + | }() | |
| 76 | + | } | |
| 77 | + | if err := j.prepare(ctx, s.num, kids, 1, n); err != nil { | |
| 78 | + | return err | |
| 79 | + | } | |
| 80 | + | setups := j.setupSteps() | |
| 81 | + | j.actions.pullOrder, j.actions.buildOrder = saved.pullOrder, saved.buildOrder | |
| 82 | + | j.actions.pullUsers, j.actions.buildUsers = saved.pullUsers, saved.buildUsers | |
| 83 | + | for _, q := range setups { | |
| 84 | + | if r := q.setup(s, cancel); r != resSucceeded { | |
| 85 | + | if r == resCanceled { | |
| 86 | + | return errSetupCancelled | |
| 87 | + | } | |
| 88 | + | return errSetupFailed | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // errSetupFailed ends a composite whose image setup failed; the cause is in the | |
| 95 | + | // log already. | |
| 96 | + | // errSetupCancelled: the step was cancelled while its images were prepared; the | |
| 97 | + | // cancellation is logged by the docker command already. | |
| 98 | + | var errSetupCancelled = ghErrorf("The operation was canceled.") | |
| 99 | + | ||
| 100 | + | var errSetupFailed = ghErrorf("A container setup step of the composite action failed.") | |
| 101 | + | ||
| 102 | + | // runComposite is CompositeActionHandler.RunAsync (CompositeActionHandler.cs: | |
| 103 | + | // 30-207) and RunStepsAsync: the steps of the stage run in their own steps | |
| 104 | + | // context with the inputs of the action, and the outputs are mapped from them. | |
| 105 | + | func (j *job) runComposite(s *stepRun, def *actionDef, in *inputSet, env *ordered) stepResult { | |
| 106 | + | n := s.n | |
| 107 | + | var kids []*node | |
| 108 | + | switch s.stage { | |
| 109 | + | case stagePre: | |
| 110 | + | kids = n.preSteps | |
| 111 | + | case stagePost: | |
| 112 | + | // Only the steps that registered a post, last registered first. | |
| 113 | + | for i := len(n.postSteps) - 1; i >= 0; i-- { | |
| 114 | + | if c := n.postSteps[i]; c.postRegistered { | |
| 115 | + | kids = append(kids, c) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | default: | |
| 119 | + | kids = n.children | |
| 120 | + | } | |
| 121 | + | inputs := expr.NewDictionary() | |
| 122 | + | for _, k := range in.keys { | |
| 123 | + | v, _ := in.Get(k) | |
| 124 | + | inputs.Add(k, expr.String(v)) | |
| 125 | + | } | |
| 126 | + | // The pre stage has no context name in the runner, so its steps context is a | |
| 127 | + | // random one; post shares the scope of main (siblingScopeName). | |
| 128 | + | scope := n.scope | |
| 129 | + | if s.stage == stagePre { | |
| 130 | + | scope = "__pre_" + n.scope | |
| 131 | + | } | |
| 132 | + | j.mu.Lock() | |
| 133 | + | restoreGH := j.state.GitHubKeys(compositeGitHubKeys()...) | |
| 134 | + | restoreScope := j.state.PushScope(scope, inputs) | |
| 135 | + | j.state.SetGitHub("action_path", def.dir) | |
| 136 | + | j.mu.Unlock() | |
| 137 | + | restored := false | |
| 138 | + | restore := func() { | |
| 139 | + | if !restored { | |
| 140 | + | restored = true | |
| 141 | + | restoreScope() | |
| 142 | + | restoreGH() | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | defer func() { | |
| 146 | + | j.mu.Lock() | |
| 147 | + | restore() | |
| 148 | + | j.mu.Unlock() | |
| 149 | + | }() | |
| 150 | + | ok := resSucceeded | |
| 151 | + | s.result = &ok | |
| 152 | + | for _, c := range kids { | |
| 153 | + | j.runEmbedded(s, c) | |
| 154 | + | } | |
| 155 | + | // Outputs are evaluated in the steps context of the action and belong to the | |
| 156 | + | // step that uses it (ExecutionContext.SetOutput of the composite step). | |
| 157 | + | var outs []Pair | |
| 158 | + | if s.stage == stageMain { | |
| 159 | + | outs = j.compositeOutputs(s, def) | |
| 160 | + | } | |
| 161 | + | j.mu.Lock() | |
| 162 | + | restore() | |
| 163 | + | for _, o := range outs { | |
| 164 | + | j.state.SetOutput(s.st.ID, o.Key, o.Value) | |
| 165 | + | } | |
| 166 | + | j.mu.Unlock() | |
| 167 | + | return *s.result | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // compositeOutputs is ProcessOutputs (CompositeActionHandler.cs:192-228): the | |
| 171 | + | // "value" of each output of the action, evaluated with the steps and inputs | |
| 172 | + | // of the action. | |
| 173 | + | func (j *job) compositeOutputs(s *stepRun, def *actionDef) []Pair { | |
| 174 | + | var out []Pair | |
| 175 | + | j.mu.Lock() | |
| 176 | + | defer j.mu.Unlock() | |
| 177 | + | for _, o := range def.act.Outputs { | |
| 178 | + | if o.Value == nil { | |
| 179 | + | continue | |
| 180 | + | } | |
| 181 | + | v, err := j.evalString(o.Value, s.env) | |
| 182 | + | if err != nil { | |
| 183 | + | j.mu.Unlock() | |
| 184 | + | j.errorf(s.num, "%s", templateNotValid(err, true)) | |
| 185 | + | j.mu.Lock() | |
| 186 | + | m := resFailed | |
| 187 | + | s.result = &m | |
| 188 | + | return nil | |
| 189 | + | } | |
| 190 | + | out = append(out, Pair{o.Name, v}) | |
| 191 | + | } | |
| 192 | + | return out | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // runEmbedded is one iteration of CompositeActionHandler.RunStepsAsync: the | |
| 196 | + | // step of the composite action runs inside the step that uses it, with its own | |
| 197 | + | // environment, condition, cancellation watch and result. Its log lines belong | |
| 198 | + | // to the outer step. | |
| 199 | + | func (j *job) runEmbedded(parent *stepRun, c *node) { | |
| 200 | + | s := &stepRun{j: j, num: parent.num, st: c.st, n: c, stage: parent.stage, parent: parent, fc: j.fc.Fork()} | |
| 201 | + | // The issues, summaries and state of an inner step belong to the step that | |
| 202 | + | // uses the action, whose collector counts them (10 per level, the outer | |
| 203 | + | // step's own first). | |
| 204 | + | s.scope = j.embeddedScope(parent, c.stateKey) | |
| 205 | + | defer s.fc.Cleanup() | |
| 206 | + | s.cmds = newCommandManager(s, j.cfg.Debug) | |
| 207 | + | j.mu.Lock() | |
| 208 | + | // github.action_status: the result of the composite action so far. | |
| 209 | + | j.state.SetGitHub("action_status", parent.actionStatus()) | |
| 210 | + | j.mu.Unlock() | |
| 211 | + | env, envErr := j.stepEnvironment(s) | |
| 212 | + | s.env = env | |
| 213 | + | s.name = j.displayName(c.st, env) | |
| 214 | + | if envErr != nil { | |
| 215 | + | j.errorf(s.num, "%s", templateNotValid(envErr, true)) | |
| 216 | + | j.finishStep(s, resFailed) | |
| 217 | + | return | |
| 218 | + | } | |
| 219 | + | q := qstep{n: c, stage: parent.stage} | |
| 220 | + | switch parent.stage { | |
| 221 | + | case stagePre: | |
| 222 | + | if c.def != nil { | |
| 223 | + | q.cond = c.def.preIf() | |
| 224 | + | } | |
| 225 | + | case stagePost: | |
| 226 | + | q.cond = c.postCond | |
| 227 | + | } | |
| 228 | + | j.runGuarded(s, q, true) | |
| 229 | + | } | |
A internal/executor/actions_docker.go +309 -0
| @@ -0,0 +1,309 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite-actions/action" | |
| 13 | + | "bnema.dev/ermite-actions/expr" | |
| 14 | + | "bnema.dev/ermite-actions/template" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // pathMap is ContainerInfo's path translation for a docker action: the host | |
| 18 | + | // directories mounted below /github. The runner inserts each mapping first, so | |
| 19 | + | // the last added is tried first (ContainerInfo.AddPathTranslateMapping). | |
| 20 | + | type pathMap struct{ host, container string } | |
| 21 | + | ||
| 22 | + | func translatePath(maps []pathMap, p string) string { | |
| 23 | + | if p == "" { | |
| 24 | + | return p | |
| 25 | + | } | |
| 26 | + | for _, m := range maps { | |
| 27 | + | if p == m.host { | |
| 28 | + | return m.container | |
| 29 | + | } | |
| 30 | + | if strings.HasPrefix(p, m.host+"/") { | |
| 31 | + | return m.container + p[len(m.host):] | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | return p | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // ctx is the context of the job's own operations (downloads, resolutions): it | |
| 38 | + | // ends when the job is cancelled. | |
| 39 | + | func (j *job) ctx() context.Context { | |
| 40 | + | if j.jobCtx == nil { | |
| 41 | + | return context.Background() | |
| 42 | + | } | |
| 43 | + | return j.jobCtx | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // dockerEnvContext evaluates against the contexts of the step with the inputs | |
| 47 | + | // context replaced: the arguments and the env of a container action see its | |
| 48 | + | // inputs, with the defaults (ContainerActionHandler.cs:122-131). | |
| 49 | + | func (j *job) dockerEnvContext(env *ordered, in *inputSet) template.Env { | |
| 50 | + | te := j.evalEnv(env) | |
| 51 | + | ctxs := make(map[string]expr.Value, len(te.Contexts)+1) | |
| 52 | + | for k, v := range te.Contexts { | |
| 53 | + | ctxs[k] = v | |
| 54 | + | } | |
| 55 | + | d := expr.NewDictionary() | |
| 56 | + | for _, k := range in.keys { | |
| 57 | + | v, _ := in.Get(k) | |
| 58 | + | d.Add(k, expr.String(v)) | |
| 59 | + | } | |
| 60 | + | ctxs["inputs"] = d | |
| 61 | + | te.Contexts = ctxs | |
| 62 | + | return te | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // quoteArg is the quoting of one argument of the entrypoint | |
| 66 | + | // (ContainerActionHandler.cs:139-148): in double quotes, a quote preceded by a | |
| 67 | + | // backslash; an empty argument is "". | |
| 68 | + | func quoteArg(a string) string { | |
| 69 | + | if a == "" { | |
| 70 | + | return `""` | |
| 71 | + | } | |
| 72 | + | return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"` | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // dockerBuildCmd is docker build (DockerCommandManager.DockerBuild). | |
| 76 | + | func (j *job) dockerBuildCmd(tag, dockerfile, context string) dockerCmd { | |
| 77 | + | return j.newDockerCmd("build", | |
| 78 | + | dockerPart{line: "-t " + tag, args: []string{"-t", tag}}, | |
| 79 | + | dockerPart{line: `-f "` + dockerfile + `"`, args: []string{"-f", dockerfile}}, | |
| 80 | + | dockerPart{line: `"` + context + `"`, args: []string{context}}) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // buildImage builds an image from a Dockerfile, in the step. | |
| 84 | + | func (j *job) buildImage(s *stepRun, tag, dockerfile, workDir string, cancel <-chan struct{}) error { | |
| 85 | + | c := j.dockerBuildCmd(tag, dockerfile, filepath.Dir(dockerfile)) | |
| 86 | + | code, err := j.runDockerCmd(s, c, dirOrCwd(workDir), nil, cancel) | |
| 87 | + | if err != nil { | |
| 88 | + | return err | |
| 89 | + | } | |
| 90 | + | if code != 0 { | |
| 91 | + | return ghErrorf("Docker build failed with exit code %d", code) | |
| 92 | + | } | |
| 93 | + | return nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // runDocker is ContainerActionHandler.RunAsync (ContainerActionHandler.cs: | |
| 97 | + | // 34-277) in a sandbox that has the docker CLI: the same mounts, variables and | |
| 98 | + | // options as the runner's docker run. | |
| 99 | + | func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult { | |
| 100 | + | num, n := s.num, s.n | |
| 101 | + | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | |
| 102 | + | for _, k := range in.keys { | |
| 103 | + | v, _ := in.Get(k) | |
| 104 | + | env.Set(inputEnvName(k), v) | |
| 105 | + | } | |
| 106 | + | j.mu.Lock() | |
| 107 | + | workspace := j.state.GitHub("workspace") | |
| 108 | + | temp := j.state.Layout().Temp | |
| 109 | + | j.mu.Unlock() | |
| 110 | + | image := n.image | |
| 111 | + | if image == "" { | |
| 112 | + | var build string | |
| 113 | + | if def.act == nil && def.dockerfile != "" { | |
| 114 | + | build = def.dockerfile | |
| 115 | + | } else if def.act == nil { | |
| 116 | + | image = def.image | |
| 117 | + | } else { | |
| 118 | + | im, err := action.ParseImage(def.image) | |
| 119 | + | if err != nil { | |
| 120 | + | return fail(err) | |
| 121 | + | } | |
| 122 | + | if im.Kind == action.ImageRegistry { | |
| 123 | + | image = im.Ref | |
| 124 | + | } else { | |
| 125 | + | build = filepath.Join(def.dir, filepath.FromSlash(im.Path)) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | if build != "" { | |
| 129 | + | if _, err := os.Stat(build); err != nil { | |
| 130 | + | return fail(ghErrorf("File not found: '%s'", build)) | |
| 131 | + | } | |
| 132 | + | j.out(num, "##[group]Building docker image") | |
| 133 | + | j.out(num, fmt.Sprintf("Dockerfile for action: '%s'.", build)) | |
| 134 | + | image = newImageTag(j.dockerLabel()) | |
| 135 | + | err := j.buildImage(s, image, build, workspace, cancel) | |
| 136 | + | j.out(num, "##[endgroup]") | |
| 137 | + | if err != nil { | |
| 138 | + | if err == errCancelled { | |
| 139 | + | return j.cancelledStep(s) | |
| 140 | + | } | |
| 141 | + | return fail(err) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | var entry string | |
| 146 | + | r := def.runs() | |
| 147 | + | switch s.stage { | |
| 148 | + | case stageMain: | |
| 149 | + | if r != nil && r.Entrypoint != "" { | |
| 150 | + | entry = r.Entrypoint | |
| 151 | + | } else if v, ok := in.Get("entryPoint"); ok { | |
| 152 | + | entry = v | |
| 153 | + | } | |
| 154 | + | case stagePre: | |
| 155 | + | if r != nil { | |
| 156 | + | entry = r.PreEntrypoint | |
| 157 | + | } | |
| 158 | + | case stagePost: | |
| 159 | + | if r != nil { | |
| 160 | + | entry = r.PostEntrypoint | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | te := j.dockerEnvContext(env, in) | |
| 164 | + | argLine, argList, err := j.dockerArgs(r, in, te) | |
| 165 | + | if err != nil { | |
| 166 | + | j.errorf(num, "%s", templateNotValid(err, true)) | |
| 167 | + | return resFailed | |
| 168 | + | } | |
| 169 | + | if r != nil && r.Env != nil { | |
| 170 | + | v, err := template.Evaluate(context.Background(), r.Env, te) | |
| 171 | + | if err != nil { | |
| 172 | + | j.errorf(num, "%s", templateNotValid(err, true)) | |
| 173 | + | return resFailed | |
| 174 | + | } | |
| 175 | + | if d, ok := v.(*expr.Dictionary); ok { | |
| 176 | + | for _, p := range d.Pairs() { | |
| 177 | + | if _, has := env.Get(p.Key); !has { | |
| 178 | + | env.Set(p.Key, expr.CoerceString(p.Value)) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | // The directories the container sees (ContainerActionHandler.cs:171-198). | |
| 184 | + | home := filepath.Join(temp, "_github_home") | |
| 185 | + | workflowDir := filepath.Join(temp, "_github_workflow") | |
| 186 | + | fcDir := filepath.Join(temp, "_runner_file_commands") | |
| 187 | + | for _, d := range []string{home, workflowDir} { | |
| 188 | + | if err := os.MkdirAll(d, 0o755); err != nil { | |
| 189 | + | return fail(err) | |
| 190 | + | } | |
| 191 | + | j.own(d) | |
| 192 | + | } | |
| 193 | + | env.Set("HOME", home) | |
| 194 | + | maps := []pathMap{ // last added first | |
| 195 | + | {workspace, "/github/workspace"}, {fcDir, "/github/file_commands"}, {workflowDir, "/github/workflow"}, | |
| 196 | + | {home, "/github/home"}, {temp, "/github/runner_temp"}, | |
| 197 | + | } | |
| 198 | + | // The runtime variables of the contexts (GITHUB_*, RUNNER_*). | |
| 199 | + | j.mu.Lock() | |
| 200 | + | runtime := j.state.RuntimeEnv() | |
| 201 | + | j.mu.Unlock() | |
| 202 | + | for _, k := range runtime.Keys() { | |
| 203 | + | v, _ := runtime.Get(k) | |
| 204 | + | env.Set(k, v) | |
| 205 | + | } | |
| 206 | + | cenv := newOrdered() | |
| 207 | + | for _, k := range env.Keys() { | |
| 208 | + | v, _ := env.Get(k) | |
| 209 | + | cenv.Set(k, translatePath(maps, v)) | |
| 210 | + | } | |
| 211 | + | parts := []dockerPart{ | |
| 212 | + | raw("--name", sanitizeName(image)+"_"+shortID()), | |
| 213 | + | raw("--label", j.dockerLabel()), | |
| 214 | + | raw("--workdir", "/github/workspace"), | |
| 215 | + | raw("--rm"), | |
| 216 | + | } | |
| 217 | + | for _, k := range cenv.Keys() { | |
| 218 | + | parts = append(parts, envOpt(k)) | |
| 219 | + | } | |
| 220 | + | parts = append(parts, raw("-e", "GITHUB_ACTIONS=true")) | |
| 221 | + | if _, ok := cenv.Get("CI"); !ok { | |
| 222 | + | parts = append(parts, raw("-e", "CI=true")) | |
| 223 | + | } | |
| 224 | + | if entry != "" { | |
| 225 | + | parts = append(parts, entrypointOpt(entry)) | |
| 226 | + | } | |
| 227 | + | parts = append(parts, | |
| 228 | + | volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"), | |
| 229 | + | volumeOpt(temp, "/github/runner_temp"), | |
| 230 | + | volumeOpt(home, "/github/home"), | |
| 231 | + | volumeOpt(workflowDir, "/github/workflow"), | |
| 232 | + | volumeOpt(fcDir, "/github/file_commands"), | |
| 233 | + | volumeOpt(workspace, "/github/workspace"), | |
| 234 | + | raw(image), | |
| 235 | + | dockerPart{line: argLine, args: argList}) | |
| 236 | + | cmd := j.newDockerCmd("run", parts...) | |
| 237 | + | j.out(num, "##[command]"+cmd.line) | |
| 238 | + | // The docker process gets the container variables on top of its own | |
| 239 | + | // environment: "-e NAME" reads them from there. | |
| 240 | + | // (ProcessInvoker gets ContainerEnvironmentVariables: the translated values, | |
| 241 | + | // and nothing from the contexts on top.) | |
| 242 | + | s.rawEnvList = j.dockerProcessEnv(cenv) | |
| 243 | + | return j.runProc(s, cmd.argv[0], workspace, cmd.argv, "Docker Action run completed with exit code %d", cancel) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // dockerArgs is the "args" of the entrypoint: the manifest's list, evaluated and | |
| 247 | + | // quoted, or the "args" input as it is (ContainerActionHandler.cs:133-161). | |
| 248 | + | func (j *job) dockerArgs(r *action.Runs, in *inputSet, te template.Env) (line string, list []string, err error) { | |
| 249 | + | if r != nil && r.Args != nil { | |
| 250 | + | v, err := template.Evaluate(context.Background(), r.Args, te) | |
| 251 | + | if err != nil { | |
| 252 | + | return "", nil, err | |
| 253 | + | } | |
| 254 | + | a, _ := v.(*expr.Array) | |
| 255 | + | if a != nil { | |
| 256 | + | for _, it := range a.Items() { | |
| 257 | + | s := expr.CoerceString(it) | |
| 258 | + | line += " " + quoteArg(s) | |
| 259 | + | list = append(list, s) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | return line, list, nil | |
| 263 | + | } | |
| 264 | + | raw, _ := in.Get("args") | |
| 265 | + | return raw, splitArgs(raw), nil | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // cancelledStep logs the cancellation of a step that was running docker, unless | |
| 269 | + | // the step timed out: then StepsRunner logs the timeout message instead | |
| 270 | + | // (stepRun.logCanceled). | |
| 271 | + | func (j *job) cancelledStep(s *stepRun) stepResult { | |
| 272 | + | s.logCanceled() | |
| 273 | + | return resCanceled | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // done reports whether a cancellation channel is closed (a nil one never is). | |
| 277 | + | func done(c <-chan struct{}) bool { | |
| 278 | + | select { | |
| 279 | + | case <-c: | |
| 280 | + | return true | |
| 281 | + | default: | |
| 282 | + | return false | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func shortID() string { return strings.ReplaceAll(newImageTag(""), ":", "")[:6] } | |
| 287 | + | ||
| 288 | + | // dockerProcessEnv is the environment of the docker CLI: the one of the runner | |
| 289 | + | // and the translated container variables (DockerCommandManager.DockerRun passes | |
| 290 | + | // ContainerEnvironmentVariables as the environment of the process). | |
| 291 | + | func (j *job) dockerProcessEnv(cenv *ordered) []string { | |
| 292 | + | penv := newOrdered() | |
| 293 | + | for _, kv := range j.baseEnv { | |
| 294 | + | if k, v, ok := strings.Cut(kv, "="); ok { | |
| 295 | + | penv.Set(k, v) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | for _, k := range cenv.Keys() { | |
| 299 | + | v, _ := cenv.Get(k) | |
| 300 | + | penv.Set(k, v) | |
| 301 | + | } | |
| 302 | + | penv.Set(ProcessTrackingID, j.trackID) | |
| 303 | + | var list []string | |
| 304 | + | for _, k := range penv.Keys() { | |
| 305 | + | v, _ := penv.Get(k) | |
| 306 | + | list = append(list, k+"="+v) | |
| 307 | + | } | |
| 308 | + | return list | |
| 309 | + | } | |
A internal/executor/actions_docker_cmd.go +197 -0
| @@ -0,0 +1,197 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "encoding/hex" | |
| 8 | + | "os" | |
| 9 | + | "os/exec" | |
| 10 | + | "regexp" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "github.com/google/uuid" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // dockerPath is the docker CLI (DockerCommandManager.Initialize: which docker). | |
| 17 | + | func (j *job) dockerPath() string { | |
| 18 | + | if p := j.cfg.Actions.DockerPath; p != "" { | |
| 19 | + | return p | |
| 20 | + | } | |
| 21 | + | if p, err := exec.LookPath("docker"); err == nil { | |
| 22 | + | return p | |
| 23 | + | } | |
| 24 | + | return "/usr/bin/docker" | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // dockerLabel is DockerInstanceLabel: six hex characters that identify the | |
| 28 | + | // runner (the first characters of the SHA-256 of its .runner file). The | |
| 29 | + | // sandbox has one runner per job; the job's tracking id stands in. | |
| 30 | + | func (j *job) dockerLabel() string { | |
| 31 | + | h := sha256.Sum256([]byte(j.trackID)) | |
| 32 | + | return hex.EncodeToString(h[:])[:6] | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // escapeDockerArg is DockerUtil.EscapeString: the string wrapped in quotes with | |
| 36 | + | // the backslashes before a quote doubled (and at the end), as the runner writes | |
| 37 | + | // the arguments of its docker commands (it echoes them, so the log has them). | |
| 38 | + | var ( | |
| 39 | + | quoteEscape = regexp.MustCompile(`(\\*)"`) | |
| 40 | + | endOfStringEscape = regexp.MustCompile(`(\\+)$`) | |
| 41 | + | ) | |
| 42 | + | ||
| 43 | + | func escapeDockerArg(v string) string { | |
| 44 | + | if v == "" { | |
| 45 | + | return "" | |
| 46 | + | } | |
| 47 | + | v = quoteEscape.ReplaceAllString(v, `$1$1\"`) | |
| 48 | + | v = endOfStringEscape.ReplaceAllString(v, `$1$1`) | |
| 49 | + | return `"` + v + `"` | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // dockerCmd is a docker command: the line the runner echoes and the arguments | |
| 53 | + | // that are run. | |
| 54 | + | type dockerCmd struct { | |
| 55 | + | line string | |
| 56 | + | argv []string | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // newDockerCmd builds "docker <command> <options>" the way the runner does | |
| 60 | + | // (ExecuteDockerCommandAsync: command and options joined by a space and | |
| 61 | + | // trimmed) and keeps the argv apart, so that nothing is re-split. | |
| 62 | + | func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd { | |
| 63 | + | lines := []string{} | |
| 64 | + | argv := []string{j.dockerPath(), command} | |
| 65 | + | for _, p := range parts { | |
| 66 | + | lines = append(lines, p.line) | |
| 67 | + | argv = append(argv, p.args...) | |
| 68 | + | } | |
| 69 | + | line := strings.TrimSpace(command + " " + strings.Join(lines, " ")) | |
| 70 | + | return dockerCmd{line: j.dockerPath() + " " + line, argv: argv} | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // dockerPart is one piece of a docker command: its text in the echoed line and | |
| 74 | + | // its arguments. | |
| 75 | + | type dockerPart struct { | |
| 76 | + | line string | |
| 77 | + | args []string | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} } | |
| 81 | + | ||
| 82 | + | // envOpt is CreateEscapedOption("-e", key): the value is read from the | |
| 83 | + | // environment of the docker process. | |
| 84 | + | func envOpt(key string) dockerPart { | |
| 85 | + | return dockerPart{line: "-e " + escapeDockerArg(key), args: []string{"-e", key}} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func entrypointOpt(ep string) dockerPart { | |
| 89 | + | return dockerPart{line: `--entrypoint "` + ep + `"`, args: []string{"--entrypoint", ep}} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func volumeOpt(src, dst string) dockerPart { | |
| 93 | + | return dockerPart{ | |
| 94 | + | line: `-v "` + strings.ReplaceAll(src, `"`, `\"`) + `":"` + strings.ReplaceAll(dst, `"`, `\"`) + `"`, | |
| 95 | + | args: []string{"-v", src + ":" + dst}, | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // sanitizeName is NameValidation.Sanitize: letters, digits (not first) and "_". | |
| 100 | + | func sanitizeName(s string) string { | |
| 101 | + | var b strings.Builder | |
| 102 | + | for _, r := range s { | |
| 103 | + | switch { | |
| 104 | + | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_': | |
| 105 | + | b.WriteRune(r) | |
| 106 | + | case r >= '0' && r <= '9' && b.Len() > 0: | |
| 107 | + | b.WriteRune(r) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | return b.String() | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func newImageTag(label string) string { | |
| 114 | + | return label + ":" + strings.ReplaceAll(uuid.NewString(), "-", "") | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // splitArgs splits a command line like .NET does for the arguments of a | |
| 118 | + | // process: spaces separate, double quotes group, a backslash before a quote | |
| 119 | + | // escapes it (ArgUtil.SplitArguments is not needed: ProcessInvoker passes the | |
| 120 | + | // string to the platform, which splits it with these rules). | |
| 121 | + | func splitArgs(s string) []string { | |
| 122 | + | var out []string | |
| 123 | + | var cur strings.Builder | |
| 124 | + | in, started := false, false | |
| 125 | + | for i := 0; i < len(s); i++ { | |
| 126 | + | c := s[i] | |
| 127 | + | switch { | |
| 128 | + | case c == '\\': | |
| 129 | + | n := 0 | |
| 130 | + | for i < len(s) && s[i] == '\\' { | |
| 131 | + | n++ | |
| 132 | + | i++ | |
| 133 | + | } | |
| 134 | + | if i < len(s) && s[i] == '"' { | |
| 135 | + | cur.WriteString(strings.Repeat(`\`, n/2)) | |
| 136 | + | if n%2 == 1 { | |
| 137 | + | cur.WriteByte('"') | |
| 138 | + | } else { | |
| 139 | + | in = !in | |
| 140 | + | } | |
| 141 | + | } else { | |
| 142 | + | cur.WriteString(strings.Repeat(`\`, n)) | |
| 143 | + | i-- | |
| 144 | + | } | |
| 145 | + | started = true | |
| 146 | + | case c == '"': | |
| 147 | + | in = !in | |
| 148 | + | started = true | |
| 149 | + | case (c == ' ' || c == '\t') && !in: | |
| 150 | + | if started { | |
| 151 | + | out = append(out, cur.String()) | |
| 152 | + | cur.Reset() | |
| 153 | + | started = false | |
| 154 | + | } | |
| 155 | + | default: | |
| 156 | + | cur.WriteByte(c) | |
| 157 | + | started = true | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | if started { | |
| 161 | + | out = append(out, cur.String()) | |
| 162 | + | } | |
| 163 | + | return out | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // runDockerCmd echoes the command (##[command], ExecutionContext.Command) and | |
| 167 | + | // runs it, sending every line of its output to the log as is: the output of | |
| 168 | + | // docker is not interpreted as workflow commands (ExecuteDockerCommandAsync | |
| 169 | + | // writes it with context.Output). It returns the exit code. | |
| 170 | + | func (j *job) runDockerCmd(s *stepRun, c dockerCmd, dir string, env []string, cancel <-chan struct{}) (int, error) { | |
| 171 | + | j.out(s.num, "##[command]"+c.line) | |
| 172 | + | if env == nil { | |
| 173 | + | env = j.processEnv(newOrdered()) | |
| 174 | + | } | |
| 175 | + | res, err := runProcess(procSpec{ | |
| 176 | + | Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential, | |
| 177 | + | OnLine: func(line string) { j.write(s.num, line) }, | |
| 178 | + | Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |
| 179 | + | }) | |
| 180 | + | if err != nil { | |
| 181 | + | return 0, err | |
| 182 | + | } | |
| 183 | + | if res.Cancelled { | |
| 184 | + | return res.ExitCode, errCancelled | |
| 185 | + | } | |
| 186 | + | return res.ExitCode, nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // errCancelled is a docker command stopped by the cancellation of the step. | |
| 190 | + | var errCancelled = ghErrorf("The operation was canceled.") | |
| 191 | + | ||
| 192 | + | func dirOrCwd(d string) string { | |
| 193 | + | if st, err := os.Stat(d); err == nil && st.IsDir() { | |
| 194 | + | return d | |
| 195 | + | } | |
| 196 | + | return "" | |
| 197 | + | } | |
A internal/executor/actions_docker_test.go +231 -0
| @@ -0,0 +1,231 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // fakeDocker is a docker CLI that appends its arguments, one per line between | |
| 14 | + | // "--" markers, and the values of the variables it was told to look at, to a | |
| 15 | + | // file. pull and build succeed, "run" prints what it was given. | |
| 16 | + | func fakeDocker(t *testing.T, failPulls int) (path, logFile string) { | |
| 17 | + | t.Helper() | |
| 18 | + | dir := t.TempDir() | |
| 19 | + | logFile = filepath.Join(dir, "calls") | |
| 20 | + | counter := filepath.Join(dir, "pulls") | |
| 21 | + | script := `#!/bin/sh | |
| 22 | + | { echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; } >> ` + logFile + ` | |
| 23 | + | case "$1" in | |
| 24 | + | pull) | |
| 25 | + | n=$(cat ` + counter + ` 2>/dev/null || echo 0); n=$((n+1)); echo $n > ` + counter + ` | |
| 26 | + | if [ "$n" -le ` + string(rune('0'+failPulls)) + ` ]; then echo "pull failed"; exit 1; fi | |
| 27 | + | echo "pulled $2" ;; | |
| 28 | + | build) echo "built" ;; | |
| 29 | + | run) | |
| 30 | + | echo "RUN-ENV INPUT_WHO=$INPUT_WHO HOME=$HOME GITHUB_ENV=$GITHUB_ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE FOO=$FOO" | |
| 31 | + | echo "::set-output name=x::y" ;; | |
| 32 | + | esac | |
| 33 | + | exit 0 | |
| 34 | + | ` | |
| 35 | + | if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | return filepath.Join(dir, "docker"), logFile | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func calls(t *testing.T, logFile string) [][]string { | |
| 42 | + | t.Helper() | |
| 43 | + | b, _ := os.ReadFile(logFile) | |
| 44 | + | var out [][]string | |
| 45 | + | for _, blk := range strings.Split(string(b), "--\n") { | |
| 46 | + | if blk = strings.TrimRight(blk, "\n"); blk != "" { | |
| 47 | + | out = append(out, strings.Split(blk, "\n")) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | return out | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func dockerRun(t *testing.T, tree files, yaml string, failPulls int) (*recorder, Result, [][]string, *Layout) { | |
| 54 | + | t.Helper() | |
| 55 | + | d, lf := fakeDocker(t, failPulls) | |
| 56 | + | var lay *Layout | |
| 57 | + | rec, res := actionRun(t, tree, yaml, func(c *Config) { | |
| 58 | + | c.Actions.DockerPath = d | |
| 59 | + | c.Actions.Backoff = func() time.Duration { return time.Millisecond } | |
| 60 | + | lay = c.Layout | |
| 61 | + | }) | |
| 62 | + | return rec, res, calls(t, lf), lay | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestDockerHubImageAction(t *testing.T) { | |
| 66 | + | rec, res, cs, lay := dockerRun(t, files{}, " - uses: docker://alpine:3.20\n with:\n entryPoint: /bin/sh\n args: -c \"echo hi there\"\n env:\n FOO: bar\n", 0) | |
| 67 | + | if res.Conclusion != ConclusionSuccess { | |
| 68 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 69 | + | } | |
| 70 | + | want := []string{"Set up job", "Pull alpine:3.20", "Run alpine:3.20", "Complete job"} | |
| 71 | + | if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") { | |
| 72 | + | t.Fatalf("steps %v", got) | |
| 73 | + | } | |
| 74 | + | pull := logOf(rec.steps[1]) | |
| 75 | + | if !strings.HasPrefix(pull, "##[group]Pull down action image 'alpine:3.20'\n##[command]") || !strings.Contains(pull, " pull alpine:3.20\npulled alpine:3.20\n##[endgroup]") { | |
| 76 | + | t.Errorf("pull:\n%s", pull) | |
| 77 | + | } | |
| 78 | + | run := logOf(rec.steps[2]) | |
| 79 | + | if !strings.Contains(run, "##[group]Run docker://alpine:3.20") || !strings.Contains(run, " entryPoint: /bin/sh") { | |
| 80 | + | t.Errorf("run:\n%s", run) | |
| 81 | + | } | |
| 82 | + | if len(cs) != 2 { | |
| 83 | + | t.Fatalf("calls %v", cs) | |
| 84 | + | } | |
| 85 | + | r := cs[1] | |
| 86 | + | has := func(a ...string) bool { | |
| 87 | + | for i := 0; i+len(a) <= len(r); i++ { | |
| 88 | + | if strings.Join(r[i:i+len(a)], "\x00") == strings.Join(a, "\x00") { | |
| 89 | + | return true | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | return false | |
| 93 | + | } | |
| 94 | + | ws, tmp := filepath.Join(lay.Workspace), lay.Temp | |
| 95 | + | for _, w := range [][]string{ | |
| 96 | + | {"--workdir", "/github/workspace"}, {"--rm"}, {"-e", "INPUT_ENTRYPOINT"}, {"-e", "FOO"}, {"-e", "GITHUB_ACTIONS=true"}, {"-e", "CI=true"}, | |
| 97 | + | {"--entrypoint", "/bin/sh"}, {"-v", "/var/run/docker.sock:/var/run/docker.sock"}, {"-v", tmp + ":/github/runner_temp"}, | |
| 98 | + | {"-v", tmp + "/_github_home:/github/home"}, {"-v", tmp + "/_github_workflow:/github/workflow"}, | |
| 99 | + | {"-v", tmp + "/_runner_file_commands:/github/file_commands"}, {"-v", ws + ":/github/workspace"}, | |
| 100 | + | {"alpine:3.20", "-c", "echo hi there"}, | |
| 101 | + | } { | |
| 102 | + | if !has(w...) { | |
| 103 | + | t.Errorf("run lacks %q:\n%s", w, strings.Join(r, "\n")) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | // The docker CLI gets the variables as the container sees them. | |
| 107 | + | if !strings.Contains(run, "FOO=bar") || !strings.Contains(run, "HOME=/github/home") || !strings.Contains(run, "GITHUB_ENV=/github/file_commands/set_env_") || | |
| 108 | + | !strings.Contains(run, "GITHUB_WORKSPACE=/github/workspace ") { | |
| 109 | + | t.Errorf("env of the docker process:\n%s", run) | |
| 110 | + | } | |
| 111 | + | // The echoed command line has the runner's quoting. | |
| 112 | + | if !strings.Contains(run, `--entrypoint "/bin/sh"`) || !strings.Contains(run, `-e "FOO"`) || !strings.Contains(run, ` alpine:3.20 -c "echo hi there"`) { | |
| 113 | + | t.Errorf("command line:\n%s", run) | |
| 114 | + | } | |
| 115 | + | if !strings.Contains(run, "-v \""+tmp+"\":\"/github/runner_temp\"") { | |
| 116 | + | t.Errorf("volume quoting:\n%s", run) | |
| 117 | + | } | |
| 118 | + | // Inside the container the file command paths are the mounted ones; here the | |
| 119 | + | // fake docker saw the host's, and what "-e" passes is translated. | |
| 120 | + | if !strings.Contains(strings.Join(r, "\n"), "-e\nGITHUB_ENV\n") { | |
| 121 | + | t.Errorf("GITHUB_ENV is passed by name:\n%s", strings.Join(r, "\n")) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestDockerPullRetries(t *testing.T) { | |
| 126 | + | rec, res, cs, _ := dockerRun(t, files{}, " - uses: docker://busybox\n", 2) | |
| 127 | + | if res.Conclusion != ConclusionSuccess || len(cs) != 4 { | |
| 128 | + | t.Fatalf("%+v calls %d", res, len(cs)) | |
| 129 | + | } | |
| 130 | + | if got := logOf(rec.steps[1]); strings.Count(got, "##[warning]Docker pull failed with exit code 1, back off 0.001 seconds before retry.") != 2 { | |
| 131 | + | t.Errorf("%s", got) | |
| 132 | + | } | |
| 133 | + | rec, res, _, _ = dockerRun(t, files{}, " - uses: docker://busybox\n", 5) | |
| 134 | + | if res.Conclusion != ConclusionFailure || rec.steps[1].Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[1]), "##[error]Docker pull failed with exit code 1") { | |
| 135 | + | t.Fatalf("%+v\n%s", res, logOf(rec.steps[1])) | |
| 136 | + | } | |
| 137 | + | if len(rec.steps) != 4 || rec.steps[2].Conclusion != ConclusionSkipped { | |
| 138 | + | t.Errorf("a failed pull skips the step: %v", rec.names()) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestDockerfileAction(t *testing.T) { | |
| 143 | + | tree := files{ | |
| 144 | + | "act/action.yml": "name: d\ninputs:\n who:\n default: me\nruns:\n using: docker\n image: Dockerfile\n entrypoint: /entry.sh\n args:\n - hello\n - ${{ inputs.who }}\n - ''\n - 'a \"quoted\" one'\n env:\n ACT_ENV: ${{ inputs.who }}\n", | |
| 145 | + | "act/Dockerfile": "FROM alpine\n", | |
| 146 | + | } | |
| 147 | + | rec, res, cs, _ := dockerRun(t, tree, " - uses: ./act\n with:\n who: Ermite\n", 0) | |
| 148 | + | if res.Conclusion != ConclusionSuccess { | |
| 149 | + | t.Fatalf("%+v\n%s", res, logOf(rec.steps[1])) | |
| 150 | + | } | |
| 151 | + | got := logOf(rec.step(t, "Run ./act")) | |
| 152 | + | // A local Dockerfile action builds in its own step (ContainerActionHandler.cs:64-84). | |
| 153 | + | if !strings.Contains(got, "##[group]Building docker image\nDockerfile for action: '") || !strings.Contains(got, "##[command]") { | |
| 154 | + | t.Errorf("%s", got) | |
| 155 | + | } | |
| 156 | + | if len(cs) != 2 || cs[0][0] != "build" || cs[1][0] != "run" { | |
| 157 | + | t.Fatalf("calls %v", cs) | |
| 158 | + | } | |
| 159 | + | if cs[0][1] != "-t" || !strings.Contains(cs[0][2], ":") || cs[0][3] != "-f" || !strings.HasSuffix(cs[0][4], "/act/Dockerfile") || !strings.HasSuffix(cs[0][5], "/act") { | |
| 160 | + | t.Errorf("build args %v", cs[0]) | |
| 161 | + | } | |
| 162 | + | run := strings.Join(cs[1], "\n") | |
| 163 | + | if !strings.Contains(run, "--entrypoint\n/entry.sh\n") || !strings.HasSuffix(run, "\nhello\nEr"+"mite\n\na \"quoted\" one") { | |
| 164 | + | t.Errorf("args:\n%s", run) | |
| 165 | + | } | |
| 166 | + | if !strings.Contains(got, ` "hello" "Ermite" "" "a \"quoted\" one"`) { | |
| 167 | + | t.Errorf("echoed args:\n%s", got) | |
| 168 | + | } | |
| 169 | + | if !strings.Contains(run, "-e\nACT_ENV\n") { | |
| 170 | + | t.Errorf("env of the manifest:\n%s", run) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestRepositoryDockerfileActionBuildsInSetup(t *testing.T) { | |
| 175 | + | forge := newForge(t, map[string][]byte{"/tarball/o/d/v1": tarOf(t, "o-d-x", files{"Dockerfile": "FROM alpine\n"})}) | |
| 176 | + | d, lf := fakeDocker(t, 0) | |
| 177 | + | rec, res := actionRun(t, files{}, " - uses: o/d@v1\n - uses: o/d@v1\n", func(c *Config) { | |
| 178 | + | c.Actions.DockerPath = d | |
| 179 | + | c.Actions.Resolver = forge.resolver("") | |
| 180 | + | }) | |
| 181 | + | if res.Conclusion != ConclusionSuccess { | |
| 182 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 183 | + | } | |
| 184 | + | want := "Set up job|Build o/d@v1|Run o/d@v1|Run o/d@v1|Complete job" | |
| 185 | + | if got := strings.Join(rec.names(), "|"); got != want { | |
| 186 | + | t.Fatalf("%s", got) | |
| 187 | + | } | |
| 188 | + | if got := logOf(rec.steps[1]); !strings.HasPrefix(got, "##[group]Build container for action use: '") || !strings.Contains(got, "/o/d/v1/Dockerfile'.\n##[command]") || !strings.HasSuffix(got, "\nbuilt\n##[endgroup]") { | |
| 189 | + | t.Errorf("build step:\n%s", got) | |
| 190 | + | } | |
| 191 | + | if n := len(calls(t, lf)); n != 3 { | |
| 192 | + | t.Errorf("one build for the two steps, then two runs: %d calls", n) | |
| 193 | + | } | |
| 194 | + | // Both runs use the image the setup step built. | |
| 195 | + | cs := calls(t, lf) | |
| 196 | + | if cs[1][len(cs[1])-1] != cs[2][len(cs[2])-1] && cs[1][len(cs[1])-2] != cs[2][len(cs[2])-2] { | |
| 197 | + | t.Errorf("%v %v", cs[1], cs[2]) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestEscapeDockerArg(t *testing.T) { | |
| 202 | + | for in, want := range map[string]string{ | |
| 203 | + | "": "", "A": `"A"`, `a"b`: `"a\"b"`, `a\"b`: `"a\\\"b"`, `a\`: `"a\\"`, `a\\`: `"a\\\\"`, `a b`: `"a b"`, | |
| 204 | + | } { | |
| 205 | + | if got := escapeDockerArg(in); got != want { | |
| 206 | + | t.Errorf("%q: %q, want %q", in, got, want) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestSplitArgs(t *testing.T) { | |
| 212 | + | for in, want := range map[string]string{ | |
| 213 | + | ``: ``, `a b c`: `a|b|c`, `-c "echo hi"`: `-c|echo hi`, `a\"b`: `a"b`, `"" x`: `|x`, `a"b c"d`: `ab cd`, | |
| 214 | + | } { | |
| 215 | + | if got := strings.Join(splitArgs(in), "|"); got != want { | |
| 216 | + | t.Errorf("%q: %q, want %q", in, got, want) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func TestTranslatePath(t *testing.T) { | |
| 222 | + | maps := []pathMap{{"/w/ws", "/github/workspace"}, {"/w/tmp/_github_home", "/github/home"}, {"/w/tmp", "/github/runner_temp"}} | |
| 223 | + | for in, want := range map[string]string{ | |
| 224 | + | "/w/ws": "/github/workspace", "/w/ws/a/b": "/github/workspace/a/b", "/w/tmp/_github_home/x": "/github/home/x", | |
| 225 | + | "/w/tmp/f": "/github/runner_temp/f", "/w/wsx": "/w/wsx", "x": "x", "": "", | |
| 226 | + | } { | |
| 227 | + | if got := translatePath(maps, in); got != want { | |
| 228 | + | t.Errorf("%q: %q, want %q", in, got, want) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | } | |
A internal/executor/actions_download.go +257 -0
| @@ -0,0 +1,257 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "math/rand/v2" | |
| 12 | + | "net/http" | |
| 13 | + | "os" | |
| 14 | + | "path/filepath" | |
| 15 | + | "slices" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/google/uuid" | |
| 19 | + | ||
| 20 | + | "bnema.dev/ermite-runner/internal/urlorigin" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760). | |
| 24 | + | const ( | |
| 25 | + | downloadAttempts = 3 | |
| 26 | + | downloadTimeout = 20 * time.Minute | |
| 27 | + | // maxDownloadBytes bounds an archive (the runner has no bound). | |
| 28 | + | maxDownloadBytes = 1 << 30 | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // noBackoffEnv is the runner's switch for tests (ActionManager.cs:1739). | |
| 32 | + | const noBackoffEnv = "_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF" | |
| 33 | + | ||
| 34 | + | // errActionNotFound is ActionNotFoundException (ActionNotFoundException.cs:28). | |
| 35 | + | type errActionNotFound struct{ url string } | |
| 36 | + | ||
| 37 | + | func (e errActionNotFound) Error() string { | |
| 38 | + | return fmt.Sprintf("An action could not be found at the URI '%s'", e.url) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // errAccessDenied is AccessDeniedException; the request id is empty (ermite | |
| 42 | + | // has none) and the runner still prints its parentheses. | |
| 43 | + | type errAccessDenied struct{ url string } | |
| 44 | + | ||
| 45 | + | func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) } | |
| 46 | + | ||
| 47 | + | // originOf is the canonical origin of a URL (urlorigin.Canonical): the one the | |
| 48 | + | // host and the executor both compare, scheme, host and port with the default | |
| 49 | + | // port filled in, "" for anything that is not an absolute http(s) URL. | |
| 50 | + | func originOf(raw string) string { return urlorigin.Canonical(raw) } | |
| 51 | + | ||
| 52 | + | // tokenAllowed reports whether the token of ra may be sent to its tarball URL: | |
| 53 | + | // the URL is on exactly the origin the host named, compared in canonical form | |
| 54 | + | // (default ports filled in, IPv6 hosts in brackets). | |
| 55 | + | func tokenAllowed(ra ResolvedAction) bool { | |
| 56 | + | o := originOf(ra.AuthOrigin) | |
| 57 | + | return ra.Token != "" && o != "" && o == originOf(ra.TarballURL) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // archiveClient returns the client that downloads the archives. It follows | |
| 61 | + | // redirects (GitHub's codeload does redirect) but never carries credentials | |
| 62 | + | // across an origin: the Authorization header is dropped as soon as the scheme, | |
| 63 | + | // the host or the port changes, and a redirect from https to http is refused. | |
| 64 | + | // (net/http alone would keep the header for subdomains and ignore the scheme and | |
| 65 | + | // the port.) Tests may supply their own transport through Actions.HTTPClient; | |
| 66 | + | // its redirect policy is replaced. | |
| 67 | + | func (j *job) archiveClient() *http.Client { | |
| 68 | + | c := &http.Client{} | |
| 69 | + | if base := j.cfg.Actions.HTTPClient; base != nil { | |
| 70 | + | cp := *base | |
| 71 | + | c = &cp | |
| 72 | + | } | |
| 73 | + | c.CheckRedirect = func(req *http.Request, via []*http.Request) error { | |
| 74 | + | if len(via) >= 10 { | |
| 75 | + | return errors.New("stopped after 10 redirects") | |
| 76 | + | } | |
| 77 | + | // Go builds every redirected request from the headers of the first one | |
| 78 | + | // (copying Authorization when the host matches), so the header must be | |
| 79 | + | // removed again on every hop that is not on the origin the token was meant | |
| 80 | + | // for: the first request's. Once the chain left that origin, it never gets | |
| 81 | + | // the header back, even if a later hop returns to it (A -> B -> A would | |
| 82 | + | // otherwise be re-authorised by the copy). | |
| 83 | + | origin := originOf(via[0].URL.String()) | |
| 84 | + | chain := slices.Concat(via[1:], []*http.Request{req}) // not append: via belongs to the client | |
| 85 | + | for i, r := range chain { | |
| 86 | + | prev := via[i] | |
| 87 | + | if prev.URL.Scheme == "https" && r.URL.Scheme != "https" { | |
| 88 | + | return fmt.Errorf("refusing to follow a redirect from https to %s", r.URL.Scheme) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | for _, r := range chain { | |
| 92 | + | if originOf(r.URL.String()) != origin { | |
| 93 | + | req.Header.Del("Authorization") | |
| 94 | + | break | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | return nil | |
| 98 | + | } | |
| 99 | + | return c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper. | |
| 103 | + | // GetRandomBackoff), or what the test hook says. | |
| 104 | + | func (j *job) sleepBackoff(ctx context.Context) time.Duration { | |
| 105 | + | if d := j.cfg.Actions.Backoff; d != nil { | |
| 106 | + | return d() | |
| 107 | + | } | |
| 108 | + | return time.Duration(10+rand.Int64N(21)) * time.Second | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // downloadArchive saves the archive of an action in file, retrying like the | |
| 112 | + | // runner: three attempts, a warning after each failure, a 404 or a 403 at once. | |
| 113 | + | // The final error is the one of the last attempt, which is what the runner | |
| 114 | + | // shows (PrepareActionsAsync prints the inner exception of | |
| 115 | + | // FailedToDownloadActionException). | |
| 116 | + | func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, file string) error { | |
| 117 | + | url := ra.TarballURL | |
| 118 | + | var last error | |
| 119 | + | for attempt := 1; attempt <= downloadAttempts; attempt++ { | |
| 120 | + | err := j.fetch(ctx, ra, file) | |
| 121 | + | if err == nil { | |
| 122 | + | return nil | |
| 123 | + | } | |
| 124 | + | last = err | |
| 125 | + | var nf errActionNotFound | |
| 126 | + | var ad errAccessDenied | |
| 127 | + | if ctx.Err() != nil || errors.As(err, &ad) { | |
| 128 | + | return err | |
| 129 | + | } | |
| 130 | + | if errors.As(err, &nf) { | |
| 131 | + | return err | |
| 132 | + | } | |
| 133 | + | if attempt == downloadAttempts { | |
| 134 | + | break | |
| 135 | + | } | |
| 136 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Failed to download action '%s'. Error: %s ", url, err.Error())) | |
| 137 | + | if os.Getenv(noBackoffEnv) == "" { | |
| 138 | + | d := j.sleepBackoff(ctx) | |
| 139 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Back off %v seconds before retry.", d.Seconds())) | |
| 140 | + | select { | |
| 141 | + | case <-time.After(d): | |
| 142 | + | case <-ctx.Done(): | |
| 143 | + | return ctx.Err() | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | return last | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error { | |
| 151 | + | url, token := ra.TarballURL, ra.Token | |
| 152 | + | // The token goes only to the origin the host named, never by the URL alone. | |
| 153 | + | if !tokenAllowed(ra) { | |
| 154 | + | token = "" | |
| 155 | + | } | |
| 156 | + | ctx, cancel := context.WithTimeout(ctx, downloadTimeout) | |
| 157 | + | defer cancel() | |
| 158 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |
| 159 | + | if err != nil { | |
| 160 | + | return err | |
| 161 | + | } | |
| 162 | + | if token != "" { | |
| 163 | + | req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))) | |
| 164 | + | } | |
| 165 | + | req.Header.Set("User-Agent", "ermite-runner") | |
| 166 | + | resp, err := j.archiveClient().Do(req) | |
| 167 | + | if err != nil { | |
| 168 | + | return err | |
| 169 | + | } | |
| 170 | + | defer func() { _ = resp.Body.Close() }() | |
| 171 | + | switch { | |
| 172 | + | case resp.StatusCode == http.StatusNotFound: | |
| 173 | + | return errActionNotFound{url} | |
| 174 | + | case resp.StatusCode == http.StatusForbidden: | |
| 175 | + | return errAccessDenied{url} | |
| 176 | + | case resp.StatusCode < 200 || resp.StatusCode > 299: | |
| 177 | + | // HttpResponseMessage.EnsureSuccessStatusCode. | |
| 178 | + | return ghErrorf("Response status code does not indicate success: %d (%s).", resp.StatusCode, http.StatusText(resp.StatusCode)) | |
| 179 | + | } | |
| 180 | + | f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) | |
| 181 | + | if err != nil { | |
| 182 | + | return err | |
| 183 | + | } | |
| 184 | + | n, err := io.Copy(f, io.LimitReader(resp.Body, maxDownloadBytes+1)) | |
| 185 | + | if cerr := f.Close(); err == nil { | |
| 186 | + | err = cerr | |
| 187 | + | } | |
| 188 | + | if err == nil && n > maxDownloadBytes { | |
| 189 | + | err = errors.New("the archive is larger than 1 GiB") | |
| 190 | + | } | |
| 191 | + | return err | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // watermark is the file that says an action was downloaded | |
| 195 | + | // (ActionManager.GetWatermarkFilePath). | |
| 196 | + | func watermark(dir string) string { return dir + ".completed" } | |
| 197 | + | ||
| 198 | + | // downloadAction downloads and unpacks one action into dir | |
| 199 | + | // (ActionManager.DownloadRepositoryActionAsync): in a temporary directory, the | |
| 200 | + | // archive's single top directory stripped, renamed to dir on success, and | |
| 201 | + | // marked with the watermark. | |
| 202 | + | func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, name, ref, dir string) error { | |
| 203 | + | if _, err := os.Stat(watermark(dir)); err == nil { | |
| 204 | + | j.debug(num, fmt.Sprintf("Action '%s@%s' already downloaded at '%s'.", name, ref, dir)) | |
| 205 | + | return nil | |
| 206 | + | } | |
| 207 | + | j.out(num, fmt.Sprintf("Download action repository '%s@%s' (SHA:%s)", name, ref, ra.ResolvedSHA)) | |
| 208 | + | ||
| 209 | + | actions := j.state.Layout().Actions | |
| 210 | + | tmp := filepath.Join(actions, "_temp_"+uuid.NewString()) | |
| 211 | + | if err := os.MkdirAll(tmp, 0o755); err != nil { | |
| 212 | + | return err | |
| 213 | + | } | |
| 214 | + | defer func() { _ = os.RemoveAll(tmp) }() | |
| 215 | + | archive := filepath.Join(tmp, uuid.NewString()+".tar.gz") | |
| 216 | + | if err := j.downloadArchive(ctx, num, ra, archive); err != nil { | |
| 217 | + | return err | |
| 218 | + | } | |
| 219 | + | staging := filepath.Join(tmp, "_staging") | |
| 220 | + | if err := os.Mkdir(staging, 0o755); err != nil { | |
| 221 | + | return err | |
| 222 | + | } | |
| 223 | + | af, err := os.Open(archive) | |
| 224 | + | if err != nil { | |
| 225 | + | return err | |
| 226 | + | } | |
| 227 | + | var owner *[2]int | |
| 228 | + | if c := j.cfg.Credential; c != nil { | |
| 229 | + | owner = &[2]int{int(c.Uid), int(c.Gid)} | |
| 230 | + | } | |
| 231 | + | // The top directory is stripped while extracting, so that links are checked | |
| 232 | + | // against the tree they will live in (see extractTarball). | |
| 233 | + | err = extractTarball(ctx, af, staging, owner) | |
| 234 | + | _ = af.Close() | |
| 235 | + | if err != nil { | |
| 236 | + | return ghErrorf("Can't extract the archive of action %s@%s: %v", name, ref, err) | |
| 237 | + | } | |
| 238 | + | _ = os.RemoveAll(dir) | |
| 239 | + | if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil { | |
| 240 | + | return err | |
| 241 | + | } | |
| 242 | + | if err := os.Rename(staging, dir); err != nil { | |
| 243 | + | return err | |
| 244 | + | } | |
| 245 | + | j.debug(num, fmt.Sprintf("Unwrap the archive to '%s'", dir)) | |
| 246 | + | if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil { | |
| 247 | + | return err | |
| 248 | + | } | |
| 249 | + | j.own(dir) | |
| 250 | + | _ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error { | |
| 251 | + | if err == nil { | |
| 252 | + | j.own(p) | |
| 253 | + | } | |
| 254 | + | return nil | |
| 255 | + | }) | |
| 256 | + | return nil | |
| 257 | + | } | |
A internal/executor/actions_extract.go +322 -0
| @@ -0,0 +1,322 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "archive/tar" | |
| 7 | + | "compress/gzip" | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "io/fs" | |
| 13 | + | "os" | |
| 14 | + | "path" | |
| 15 | + | "path/filepath" | |
| 16 | + | "strings" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Limits of an action archive. The runner has none (it runs tar); these keep a | |
| 20 | + | // hostile archive from filling the sandbox. They are variables for the tests. | |
| 21 | + | var ( | |
| 22 | + | maxArchiveEntries = 200_000 | |
| 23 | + | // maxArchiveBytes bounds the sizes of the regular files; maxStreamBytes the | |
| 24 | + | // whole decompressed tar stream, headers and PAX/GNU long-name records | |
| 25 | + | // included, which the entry and size counters do not see. | |
| 26 | + | maxArchiveBytes = int64(2 << 30) | |
| 27 | + | maxStreamBytes = maxArchiveBytes + 64<<20 | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | // errArchive is returned for an archive that is not safe or not valid. Its text | |
| 31 | + | // is ermite's: GitHub's runner runs tar, which accepts absolute paths and links | |
| 32 | + | // out of the tree (Runner.Worker/ActionManager.cs:1358-1394). | |
| 33 | + | type errArchive string | |
| 34 | + | ||
| 35 | + | func (e errArchive) Error() string { return string(e) } | |
| 36 | + | ||
| 37 | + | // capReader fails once more than n bytes were read. | |
| 38 | + | type capReader struct { | |
| 39 | + | r io.Reader | |
| 40 | + | n int64 | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (c *capReader) Read(p []byte) (int, error) { | |
| 44 | + | if c.n <= 0 { | |
| 45 | + | return 0, errArchive("the archive is too large once unpacked") | |
| 46 | + | } | |
| 47 | + | if int64(len(p)) > c.n { | |
| 48 | + | p = p[:c.n] | |
| 49 | + | } | |
| 50 | + | n, err := c.r.Read(p) | |
| 51 | + | c.n -= int64(n) | |
| 52 | + | return n, err | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // extractTarball unpacks a gzip-compressed tar into dir, which must exist and | |
| 56 | + | // be empty, and strips the single top directory of the archive (GitHub's | |
| 57 | + | // archives have exactly one, ActionManager.cs:1380-1393): the content lands in | |
| 58 | + | // dir itself, so no file moves afterwards and a link that is valid here stays | |
| 59 | + | // valid. Every name is checked: no absolute path, no "..", one top directory, | |
| 60 | + | // no link whose target leaves dir, and everything is written through an | |
| 61 | + | // os.Root, so that a link planted by an earlier entry cannot redirect a later | |
| 62 | + | // one. The decompressed stream is capped, and ctx is checked between entries. | |
| 63 | + | // Links are validated at install time: a running step can change the action | |
| 64 | + | // tree like any file of its own sandbox, so this is not a confinement guarantee | |
| 65 | + | // for what happens later. | |
| 66 | + | // owner, when set, is the uid and gid of the files. | |
| 67 | + | func extractTarball(ctx context.Context, r io.Reader, dir string, owner *[2]int) error { | |
| 68 | + | zr, err := gzip.NewReader(r) | |
| 69 | + | if err != nil { | |
| 70 | + | return errArchive("the archive is not a gzip file: " + err.Error()) | |
| 71 | + | } | |
| 72 | + | defer func() { _ = zr.Close() }() | |
| 73 | + | root, err := os.OpenRoot(dir) | |
| 74 | + | if err != nil { | |
| 75 | + | return err | |
| 76 | + | } | |
| 77 | + | defer func() { _ = root.Close() }() | |
| 78 | + | tr := tar.NewReader(&capReader{r: zr, n: maxStreamBytes}) | |
| 79 | + | entries, total, top := 0, int64(0), "" | |
| 80 | + | for { | |
| 81 | + | if err := ctx.Err(); err != nil { | |
| 82 | + | return err | |
| 83 | + | } | |
| 84 | + | h, err := tr.Next() | |
| 85 | + | if errors.Is(err, io.EOF) { | |
| 86 | + | return verifyLinks(ctx, root, dir) | |
| 87 | + | } | |
| 88 | + | if err != nil { | |
| 89 | + | var ea errArchive | |
| 90 | + | if errors.As(err, &ea) { | |
| 91 | + | return ea | |
| 92 | + | } | |
| 93 | + | return errArchive("the archive is not a valid tar file: " + err.Error()) | |
| 94 | + | } | |
| 95 | + | if entries++; entries > maxArchiveEntries { | |
| 96 | + | return errArchive("the archive has too many entries") | |
| 97 | + | } | |
| 98 | + | if total += h.Size; total > maxArchiveBytes { | |
| 99 | + | return errArchive("the archive is too large once unpacked") | |
| 100 | + | } | |
| 101 | + | if err := extractEntry(root, tr, h, owner, &top); err != nil { | |
| 102 | + | return err | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // stripTop validates the name of an entry, drops its top directory and returns | |
| 108 | + | // the rest, clean and relative to the final root ("" for the top directory | |
| 109 | + | // itself). All entries must share one top directory. | |
| 110 | + | func stripTop(name string, top *string) (string, error) { | |
| 111 | + | c, err := cleanEntry(name) | |
| 112 | + | if err != nil { | |
| 113 | + | return "", err | |
| 114 | + | } | |
| 115 | + | if c == "" { | |
| 116 | + | return "", nil | |
| 117 | + | } | |
| 118 | + | first, rest, hasRest := strings.Cut(c, "/") | |
| 119 | + | if *top == "" { | |
| 120 | + | *top = first | |
| 121 | + | } else if first != *top { | |
| 122 | + | return "", errArchive(fmt.Sprintf("the archive has more than one top-level directory: %q and %q", *top, first)) | |
| 123 | + | } | |
| 124 | + | if !hasRest { | |
| 125 | + | return "", nil | |
| 126 | + | } | |
| 127 | + | return rest, nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // cleanEntry validates the name of an entry and returns it clean and relative. | |
| 131 | + | func cleanEntry(name string) (string, error) { | |
| 132 | + | if strings.ContainsRune(name, 0) || strings.HasPrefix(name, "/") { | |
| 133 | + | return "", errArchive(fmt.Sprintf("the archive has an absolute or invalid path: %q", name)) | |
| 134 | + | } | |
| 135 | + | for _, seg := range strings.Split(name, "/") { | |
| 136 | + | if seg == ".." { | |
| 137 | + | return "", errArchive(fmt.Sprintf("the archive has a path that leaves its directory: %q", name)) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | c := path.Clean(name) | |
| 141 | + | if c == "." { | |
| 142 | + | return "", nil | |
| 143 | + | } | |
| 144 | + | return c, nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // linkInside reports whether a link at name pointing to target stays in the | |
| 148 | + | // tree: target is relative to the directory of name for a symlink, and to the | |
| 149 | + | // root for a hard link. | |
| 150 | + | func linkInside(name, target string, hard bool) bool { | |
| 151 | + | if target == "" || strings.HasPrefix(target, "/") || strings.ContainsRune(target, 0) { | |
| 152 | + | return false | |
| 153 | + | } | |
| 154 | + | base := path.Dir(name) | |
| 155 | + | if hard { | |
| 156 | + | base = "." | |
| 157 | + | } | |
| 158 | + | t := path.Join(base, target) | |
| 159 | + | return t != ".." && !strings.HasPrefix(t, "../") | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func extractEntry(root *os.Root, tr *tar.Reader, h *tar.Header, owner *[2]int, top *string) error { | |
| 163 | + | switch h.Typeflag { | |
| 164 | + | case tar.TypeXGlobalHeader, tar.TypeXHeader: | |
| 165 | + | return nil // git archive's pax_global_header: the commit id | |
| 166 | + | } | |
| 167 | + | name, err := stripTop(h.Name, top) | |
| 168 | + | if err != nil { | |
| 169 | + | return err | |
| 170 | + | } | |
| 171 | + | if name == "" { | |
| 172 | + | if h.Typeflag != tar.TypeDir { | |
| 173 | + | return errArchive(fmt.Sprintf("the archive has a file next to its top-level directory: %q", h.Name)) | |
| 174 | + | } | |
| 175 | + | return nil | |
| 176 | + | } | |
| 177 | + | perm := os.FileMode(h.Mode) & 0o777 | |
| 178 | + | chown := func() error { | |
| 179 | + | if owner == nil { | |
| 180 | + | return nil | |
| 181 | + | } | |
| 182 | + | return root.Lchown(name, owner[0], owner[1]) | |
| 183 | + | } | |
| 184 | + | if dir := path.Dir(name); dir != "." { | |
| 185 | + | if err := root.MkdirAll(dir, 0o755); err != nil { | |
| 186 | + | return errArchive(fmt.Sprintf("cannot create the directory of %q: %v", name, err)) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | switch h.Typeflag { | |
| 190 | + | case tar.TypeDir: | |
| 191 | + | if err := root.MkdirAll(name, 0o755); err != nil { | |
| 192 | + | return err | |
| 193 | + | } | |
| 194 | + | if err := root.Chmod(name, perm|0o700); err != nil { | |
| 195 | + | return err | |
| 196 | + | } | |
| 197 | + | return chown() | |
| 198 | + | case tar.TypeReg: | |
| 199 | + | _ = root.Remove(name) | |
| 200 | + | f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm|0o600) | |
| 201 | + | if err != nil { | |
| 202 | + | return errArchive(fmt.Sprintf("cannot create %q: %v", name, err)) | |
| 203 | + | } | |
| 204 | + | if _, err := io.Copy(f, tr); err != nil { | |
| 205 | + | _ = f.Close() | |
| 206 | + | return errArchive("the archive is not a valid tar file: " + err.Error()) | |
| 207 | + | } | |
| 208 | + | if err := f.Close(); err != nil { | |
| 209 | + | return err | |
| 210 | + | } | |
| 211 | + | if err := root.Chmod(name, perm|0o600); err != nil { | |
| 212 | + | return err | |
| 213 | + | } | |
| 214 | + | return chown() | |
| 215 | + | case tar.TypeSymlink: | |
| 216 | + | // Only the shape is checked here: where the link leads depends on the links | |
| 217 | + | // that come before and after it, so verifyLinks resolves every symlink of | |
| 218 | + | // the finished tree. | |
| 219 | + | if h.Linkname == "" || strings.HasPrefix(h.Linkname, "/") || strings.ContainsRune(h.Linkname, 0) { | |
| 220 | + | return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname)) | |
| 221 | + | } | |
| 222 | + | _ = root.Remove(name) | |
| 223 | + | if err := root.Symlink(h.Linkname, name); err != nil { | |
| 224 | + | return err | |
| 225 | + | } | |
| 226 | + | return chown() | |
| 227 | + | case tar.TypeLink: | |
| 228 | + | tgt, err := stripTop(h.Linkname, top) | |
| 229 | + | if err != nil || tgt == "" || !linkInside(name, tgt, true) { | |
| 230 | + | return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname)) | |
| 231 | + | } | |
| 232 | + | _ = root.Remove(name) | |
| 233 | + | return root.Link(tgt, name) | |
| 234 | + | } | |
| 235 | + | return nil // devices, fifos: skipped | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // maxLinkHops bounds the symbolic links followed to resolve one link (Linux | |
| 239 | + | // stops at 40). | |
| 240 | + | const maxLinkHops = 40 | |
| 241 | + | ||
| 242 | + | // verifyLinks resolves every symbolic link of the extracted tree component by | |
| 243 | + | // component, following the links met on the way, and rejects the archive when | |
| 244 | + | // one leads out of the tree or loops. A lexical check is not enough: with | |
| 245 | + | // "a -> ." a link "link -> a/../outside" looks like it stays in the tree, and | |
| 246 | + | // leaves it. A link to something that does not exist is allowed (git archives | |
| 247 | + | // have such links); the rest of its target is still resolved. | |
| 248 | + | func verifyLinks(ctx context.Context, root *os.Root, dir string) error { | |
| 249 | + | return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { | |
| 250 | + | if err != nil { | |
| 251 | + | return err | |
| 252 | + | } | |
| 253 | + | if err := ctx.Err(); err != nil { | |
| 254 | + | return err | |
| 255 | + | } | |
| 256 | + | if d.Type()&fs.ModeSymlink == 0 { | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | rel, err := filepath.Rel(dir, p) | |
| 260 | + | if err != nil { | |
| 261 | + | return err | |
| 262 | + | } | |
| 263 | + | target, err := root.Readlink(filepath.ToSlash(rel)) | |
| 264 | + | if err != nil { | |
| 265 | + | return err | |
| 266 | + | } | |
| 267 | + | if err := resolveInside(root, path.Dir(filepath.ToSlash(rel)), target); err != nil { | |
| 268 | + | return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q (%v)", filepath.ToSlash(rel), target, err)) | |
| 269 | + | } | |
| 270 | + | return nil | |
| 271 | + | }) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // resolveInside resolves target, relative to the directory dirRel of the tree | |
| 275 | + | // (slash separated, "." for the root), the way the kernel would, and fails when | |
| 276 | + | // the path leaves the root, is absolute, or needs more than maxLinkHops links. | |
| 277 | + | func resolveInside(root *os.Root, dirRel, target string) error { | |
| 278 | + | var cur []string // resolved directory components, none of them a link | |
| 279 | + | if dirRel != "." && dirRel != "" { | |
| 280 | + | cur = strings.Split(dirRel, "/") | |
| 281 | + | } | |
| 282 | + | pending := strings.Split(target, "/") | |
| 283 | + | hops := 0 | |
| 284 | + | for len(pending) > 0 { | |
| 285 | + | c := pending[0] | |
| 286 | + | pending = pending[1:] | |
| 287 | + | switch c { | |
| 288 | + | case "", ".": | |
| 289 | + | continue | |
| 290 | + | case "..": | |
| 291 | + | if len(cur) == 0 { | |
| 292 | + | return errors.New("leaves the tree") | |
| 293 | + | } | |
| 294 | + | cur = cur[:len(cur)-1] | |
| 295 | + | continue | |
| 296 | + | } | |
| 297 | + | p := strings.Join(append(append([]string(nil), cur...), c), "/") | |
| 298 | + | fi, err := root.Lstat(p) | |
| 299 | + | switch { | |
| 300 | + | case errors.Is(err, os.ErrNotExist): | |
| 301 | + | cur = append(cur, c) | |
| 302 | + | continue | |
| 303 | + | case err != nil: | |
| 304 | + | return err | |
| 305 | + | case fi.Mode()&fs.ModeSymlink == 0: | |
| 306 | + | cur = append(cur, c) | |
| 307 | + | continue | |
| 308 | + | } | |
| 309 | + | if hops++; hops > maxLinkHops { | |
| 310 | + | return errors.New("too many levels of links") | |
| 311 | + | } | |
| 312 | + | next, err := root.Readlink(p) | |
| 313 | + | if err != nil { | |
| 314 | + | return err | |
| 315 | + | } | |
| 316 | + | if strings.HasPrefix(next, "/") || next == "" { | |
| 317 | + | return errors.New("an absolute or empty link") | |
| 318 | + | } | |
| 319 | + | pending = append(strings.Split(next, "/"), pending...) | |
| 320 | + | } | |
| 321 | + | return nil | |
| 322 | + | } | |
A internal/executor/actions_extract_test.go +257 -0
| @@ -0,0 +1,257 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "archive/tar" | |
| 7 | + | "bytes" | |
| 8 | + | "compress/gzip" | |
| 9 | + | "context" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type tent struct { | |
| 17 | + | name, body, link string | |
| 18 | + | typ byte | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func tarball(t *testing.T, ents ...tent) *bytes.Reader { | |
| 22 | + | t.Helper() | |
| 23 | + | var buf bytes.Buffer | |
| 24 | + | zw := gzip.NewWriter(&buf) | |
| 25 | + | tw := tar.NewWriter(zw) | |
| 26 | + | for _, e := range ents { | |
| 27 | + | typ := e.typ | |
| 28 | + | if typ == 0 { | |
| 29 | + | typ = tar.TypeReg | |
| 30 | + | } | |
| 31 | + | h := &tar.Header{Name: e.name, Typeflag: typ, Mode: 0o644, Linkname: e.link} | |
| 32 | + | if typ == tar.TypeReg { | |
| 33 | + | h.Size = int64(len(e.body)) | |
| 34 | + | } | |
| 35 | + | if typ == tar.TypeDir { | |
| 36 | + | h.Mode = 0o755 | |
| 37 | + | } | |
| 38 | + | if err := tw.WriteHeader(h); err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | if typ == tar.TypeReg { | |
| 42 | + | _, _ = tw.Write([]byte(e.body)) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | _ = tw.Close() | |
| 46 | + | _ = zw.Close() | |
| 47 | + | return bytes.NewReader(buf.Bytes()) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestExtractTarball(t *testing.T) { | |
| 51 | + | dir := t.TempDir() | |
| 52 | + | err := extractTarball(context.Background(), tarball(t, | |
| 53 | + | tent{name: "o-r-abc/", typ: tar.TypeDir}, | |
| 54 | + | tent{name: "o-r-abc/action.yml", body: "name: x"}, | |
| 55 | + | tent{name: "o-r-abc/sub/main.js", body: "1"}, | |
| 56 | + | tent{name: "o-r-abc/link", typ: tar.TypeSymlink, link: "sub/main.js"}, | |
| 57 | + | tent{name: "o-r-abc/hard", typ: tar.TypeLink, link: "o-r-abc/sub/main.js"}, | |
| 58 | + | ), dir, nil) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | // The top directory is stripped: the content is in dir itself. | |
| 63 | + | for _, f := range []string{"action.yml", "link", "hard"} { | |
| 64 | + | if _, err := os.Stat(filepath.Join(dir, f)); err != nil { | |
| 65 | + | t.Errorf("%s: %v", f, err) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | if b, _ := os.ReadFile(filepath.Join(dir, "link")); string(b) != "1" { | |
| 69 | + | t.Errorf("link: %q", b) | |
| 70 | + | } | |
| 71 | + | if _, err := os.Stat(filepath.Join(dir, "o-r-abc")); err == nil { | |
| 72 | + | t.Error("the top directory must be stripped") | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestExtractTarballRejects(t *testing.T) { | |
| 77 | + | cases := map[string][]tent{ | |
| 78 | + | "absolute": {{name: "/etc/passwd", body: "x"}}, | |
| 79 | + | "dotdot": {{name: "t/a/../../x", body: "x"}}, | |
| 80 | + | "symlink out": {{name: "t/a/l", typ: tar.TypeSymlink, link: "../../x"}}, | |
| 81 | + | "symlink abs": {{name: "t/l", typ: tar.TypeSymlink, link: "/etc"}}, | |
| 82 | + | "hardlink out": {{name: "t/l", typ: tar.TypeLink, link: "../x"}}, | |
| 83 | + | "hardlink abs": {{name: "t/l", typ: tar.TypeLink, link: "/etc/passwd"}}, | |
| 84 | + | "through link": {{name: "t/d", typ: tar.TypeSymlink, link: "."}, {name: "t/d/../../x", body: "x"}}, | |
| 85 | + | "link then dir": {{name: "t/l", typ: tar.TypeSymlink, link: "a/.."}, {name: "t/l/../x", body: "x"}}, | |
| 86 | + | "two tops": {{name: "t/a", body: "1"}, {name: "u/b", body: "2"}}, | |
| 87 | + | "file beside": {{name: "t/", typ: tar.TypeDir}, {name: "loose", body: "x"}}, | |
| 88 | + | "hardlink other": {{name: "t/a", body: "1"}, {name: "t/l", typ: tar.TypeLink, link: "u/a"}}, | |
| 89 | + | } | |
| 90 | + | for name, ents := range cases { | |
| 91 | + | t.Run(name, func(t *testing.T) { | |
| 92 | + | parent := t.TempDir() | |
| 93 | + | dir := filepath.Join(parent, "dest") | |
| 94 | + | if err := os.Mkdir(dir, 0o755); err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | err := extractTarball(context.Background(), tarball(t, ents...), dir, nil) | |
| 98 | + | if err == nil { | |
| 99 | + | t.Fatal("accepted") | |
| 100 | + | } | |
| 101 | + | if _, statErr := os.Lstat(filepath.Join(parent, "x")); statErr == nil { | |
| 102 | + | t.Fatal("wrote outside") | |
| 103 | + | } | |
| 104 | + | }) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // GO-001: "top/link -> ../outside" stays inside the staging directory of the | |
| 109 | + | // extraction but escapes once the top directory is stripped. The target is | |
| 110 | + | // resolved against the final root, whatever the location the files are | |
| 111 | + | // extracted to, and the relocated tree has no link that leaves it. | |
| 112 | + | func TestExtractTarballLinkEscapesStrippedRoot(t *testing.T) { | |
| 113 | + | // From top/sub/l, i.e. sub/l once the top directory is stripped. | |
| 114 | + | for link, ok := range map[string]bool{"../outside": true, "..": true, "../x": true, "../../outside": false, "../../top/x": false, "../../../x": false} { | |
| 115 | + | dir := filepath.Join(t.TempDir(), "stage") | |
| 116 | + | _ = os.Mkdir(dir, 0o755) | |
| 117 | + | err := extractTarball(context.Background(), tarball(t, | |
| 118 | + | tent{name: "top/x", body: "x"}, | |
| 119 | + | tent{name: "top/sub/l", typ: tar.TypeSymlink, link: link}, | |
| 120 | + | ), dir, nil) | |
| 121 | + | if (err == nil) != ok { | |
| 122 | + | t.Errorf("link %q: accepted=%v, want %v (%v)", link, err == nil, ok, err) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | // The direct case of the finding. | |
| 126 | + | dir := filepath.Join(t.TempDir(), "stage") | |
| 127 | + | _ = os.Mkdir(dir, 0o755) | |
| 128 | + | if err := extractTarball(context.Background(), tarball(t, | |
| 129 | + | tent{name: "top/link", typ: tar.TypeSymlink, link: "../outside"}, | |
| 130 | + | ), dir, nil); err == nil { | |
| 131 | + | t.Fatal("top/link -> ../outside was accepted") | |
| 132 | + | } | |
| 133 | + | // A tree that was accepted keeps all its links inside after relocation. | |
| 134 | + | stage := filepath.Join(t.TempDir(), "stage") | |
| 135 | + | _ = os.Mkdir(stage, 0o755) | |
| 136 | + | if err := extractTarball(context.Background(), tarball(t, | |
| 137 | + | tent{name: "top/real", body: "r"}, | |
| 138 | + | tent{name: "top/a/b/l", typ: tar.TypeSymlink, link: "../../real"}, | |
| 139 | + | ), stage, nil); err != nil { | |
| 140 | + | t.Fatal(err) | |
| 141 | + | } | |
| 142 | + | final := filepath.Join(t.TempDir(), "final") | |
| 143 | + | if err := os.Rename(stage, final); err != nil { | |
| 144 | + | t.Fatal(err) | |
| 145 | + | } | |
| 146 | + | if b, err := os.ReadFile(filepath.Join(final, "a", "b", "l")); err != nil || string(b) != "r" { | |
| 147 | + | t.Fatalf("relocated link: %q %v", b, err) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestExtractTarballNotGzip(t *testing.T) { | |
| 152 | + | err := extractTarball(context.Background(), strings.NewReader("not gzip"), t.TempDir(), nil) | |
| 153 | + | if err == nil || !strings.Contains(err.Error(), "not a gzip") { | |
| 154 | + | t.Fatalf("%v", err) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // A symlink to a directory inside the tree must not let a later entry write | |
| 159 | + | // outside it, even through a chain of links. | |
| 160 | + | func TestExtractTarballLinkChain(t *testing.T) { | |
| 161 | + | parent := t.TempDir() | |
| 162 | + | dir := filepath.Join(parent, "dest") | |
| 163 | + | _ = os.Mkdir(dir, 0o755) | |
| 164 | + | err := extractTarball(context.Background(), tarball(t, | |
| 165 | + | tent{name: "t/a", typ: tar.TypeSymlink, link: "b"}, | |
| 166 | + | tent{name: "t/b", typ: tar.TypeSymlink, link: "."}, | |
| 167 | + | tent{name: "t/a/a/a/f", body: "x"}, | |
| 168 | + | ), dir, nil) | |
| 169 | + | if err != nil { | |
| 170 | + | t.Fatal(err) | |
| 171 | + | } | |
| 172 | + | if b, _ := os.ReadFile(filepath.Join(dir, "f")); string(b) != "x" { | |
| 173 | + | t.Fatalf("f: %q", b) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // GO-004: PAX and GNU long-name records are not entries and have no Size in the | |
| 178 | + | // header Next returns, so the counters do not see them. The decompressed stream | |
| 179 | + | // is capped. | |
| 180 | + | func TestExtractTarballMetadataFlood(t *testing.T) { | |
| 181 | + | old := maxStreamBytes | |
| 182 | + | maxStreamBytes = 256 << 10 | |
| 183 | + | defer func() { maxStreamBytes = old }() | |
| 184 | + | var buf bytes.Buffer | |
| 185 | + | zw := gzip.NewWriter(&buf) | |
| 186 | + | tw := tar.NewWriter(zw) | |
| 187 | + | for i := 0; i < 400; i++ { | |
| 188 | + | // A PAX record per file with a 4 KiB value; the entries stay below | |
| 189 | + | // every counter. | |
| 190 | + | h := &tar.Header{Name: "t/f", Typeflag: tar.TypeReg, Mode: 0o644, Size: 0, Format: tar.FormatPAX, | |
| 191 | + | PAXRecords: map[string]string{"comment": strings.Repeat("x", 4096)}} | |
| 192 | + | if err := tw.WriteHeader(h); err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | _ = tw.Close() | |
| 197 | + | _ = zw.Close() | |
| 198 | + | err := extractTarball(context.Background(), bytes.NewReader(buf.Bytes()), t.TempDir(), nil) | |
| 199 | + | if err == nil || !strings.Contains(err.Error(), "too large once unpacked") { | |
| 200 | + | t.Fatalf("%v", err) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestExtractTarballCancelled(t *testing.T) { | |
| 205 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 206 | + | cancel() | |
| 207 | + | err := extractTarball(ctx, tarball(t, tent{name: "t/a", body: "1"}), t.TempDir(), nil) | |
| 208 | + | if err != context.Canceled { | |
| 209 | + | t.Fatalf("%v", err) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // GO-007: where a link leads depends on the other links. "a -> ." makes | |
| 214 | + | // "link -> a/../outside" leave the tree, which a lexical check accepts. | |
| 215 | + | func TestExtractTarballLinkChainsAreResolved(t *testing.T) { | |
| 216 | + | sym := func(name, to string) tent { return tent{name: "top/" + name, typ: tar.TypeSymlink, link: to} } | |
| 217 | + | for name, tc := range map[string]struct { | |
| 218 | + | ents []tent | |
| 219 | + | ok bool | |
| 220 | + | }{ | |
| 221 | + | "two links escape": {[]tent{sym("a", "."), sym("link", "a/../outside")}, false}, | |
| 222 | + | "two links, reverse order": {[]tent{sym("link", "a/../outside"), sym("a", ".")}, false}, // forward reference | |
| 223 | + | "three hops": {[]tent{sym("a", "b"), sym("b", "."), sym("link", "a/../../x")}, false}, | |
| 224 | + | "through a directory link": {[]tent{{name: "top/d/e/f", body: "x"}, sym("d/up", "../.."), sym("link", "d/up/..")}, false}, | |
| 225 | + | "absolute inside a hop": {[]tent{sym("a", "/etc"), sym("link", "a/passwd")}, false}, | |
| 226 | + | "loop": {[]tent{sym("a", "b"), sym("b", "a"), sym("link", "a/x")}, false}, | |
| 227 | + | "long chain": {chain(45), false}, | |
| 228 | + | "stays inside": {[]tent{{name: "top/d/f", body: "1"}, sym("a", "."), sym("link", "a/d/f"), sym("up", "d/..")}, true}, | |
| 229 | + | "dangling is fine": {[]tent{sym("link", "nowhere/at/all")}, true}, | |
| 230 | + | "forward ref inside": {[]tent{sym("link", "a/b"), sym("a", "d"), {name: "top/d/b", body: "1"}}, true}, | |
| 231 | + | "dotdot after real dir": {[]tent{{name: "top/d/f", body: "1"}, sym("link", "d/../d/f")}, true}, | |
| 232 | + | } { | |
| 233 | + | t.Run(name, func(t *testing.T) { | |
| 234 | + | parent := t.TempDir() | |
| 235 | + | dir := filepath.Join(parent, "stage") | |
| 236 | + | _ = os.Mkdir(dir, 0o755) | |
| 237 | + | err := extractTarball(context.Background(), tarball(t, tc.ents...), dir, nil) | |
| 238 | + | if (err == nil) != tc.ok { | |
| 239 | + | t.Fatalf("accepted=%v, want %v (%v)", err == nil, tc.ok, err) | |
| 240 | + | } | |
| 241 | + | }) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // chain makes n links, each one to the next, the last to ".": more hops than | |
| 246 | + | // the kernel follows. | |
| 247 | + | func chain(n int) []tent { | |
| 248 | + | var out []tent | |
| 249 | + | for i := 0; i < n; i++ { | |
| 250 | + | to := "l" + string(rune('A'+(i+1)%26)) + string(rune('a'+(i+1)/26)) | |
| 251 | + | if i == n-1 { | |
| 252 | + | to = "." | |
| 253 | + | } | |
| 254 | + | out = append(out, tent{name: "top/l" + string(rune('A'+i%26)) + string(rune('a'+i/26)), typ: tar.TypeSymlink, link: to}) | |
| 255 | + | } | |
| 256 | + | return append(out, tent{name: "top/start", typ: tar.TypeSymlink, link: "lAa/x"}) | |
| 257 | + | } | |
A internal/executor/actions_inputs.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-actions/action" | |
| 11 | + | "bnema.dev/ermite-actions/expr" | |
| 12 | + | "bnema.dev/ermite-actions/template" | |
| 13 | + | "bnema.dev/ermite-actions/workflow" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // inputSet is the inputs of a step: a dictionary with OrdinalIgnoreCase keys | |
| 17 | + | // that keeps insertion order (Dictionary<string,string>, which only keeps it | |
| 18 | + | // while nothing is removed). | |
| 19 | + | type inputSet struct { | |
| 20 | + | keys []string | |
| 21 | + | vals map[string]string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func newInputSet() *inputSet { return &inputSet{vals: map[string]string{}} } | |
| 25 | + | ||
| 26 | + | func (in *inputSet) lower(k string) string { return strings.ToLower(k) } | |
| 27 | + | ||
| 28 | + | func (in *inputSet) Set(k, v string) { | |
| 29 | + | l := in.lower(k) | |
| 30 | + | if _, ok := in.vals[l]; !ok { | |
| 31 | + | in.keys = append(in.keys, k) | |
| 32 | + | } | |
| 33 | + | in.vals[l] = v | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (in *inputSet) Get(k string) (string, bool) { v, ok := in.vals[in.lower(k)]; return v, ok } | |
| 37 | + | func (in *inputSet) Has(k string) bool { _, ok := in.vals[in.lower(k)]; return ok } | |
| 38 | + | func (in *inputSet) Len() int { return len(in.keys) } | |
| 39 | + | ||
| 40 | + | // evalInputs is EvaluateStepInputs: the "with" of the step (a mapping, or the | |
| 41 | + | // expression of a composite action's step that stands for one). | |
| 42 | + | func (j *job) evalInputs(st *workflow.Step, env *ordered) (*inputSet, error) { | |
| 43 | + | in := newInputSet() | |
| 44 | + | var tok template.Token | |
| 45 | + | switch { | |
| 46 | + | case st.With != nil: | |
| 47 | + | tok = st.With | |
| 48 | + | case st.WithExpression != nil: | |
| 49 | + | tok = st.WithExpression | |
| 50 | + | default: | |
| 51 | + | return in, nil | |
| 52 | + | } | |
| 53 | + | v, err := template.Evaluate(context.Background(), tok, j.evalEnv(env)) | |
| 54 | + | if err != nil { | |
| 55 | + | return nil, err | |
| 56 | + | } | |
| 57 | + | d, ok := v.(*expr.Dictionary) | |
| 58 | + | if !ok { | |
| 59 | + | return in, nil | |
| 60 | + | } | |
| 61 | + | for _, p := range d.Pairs() { | |
| 62 | + | if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary { | |
| 63 | + | return nil, ghErrorf("Unexpected type '%s' encountered while reading 'inputs value'. The type 'StringToken' was expected.", k) | |
| 64 | + | } | |
| 65 | + | in.Set(p.Key, expr.CoerceString(p.Value)) | |
| 66 | + | } | |
| 67 | + | return in, nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // mergeInputs is the second half of ActionRunner.RunAsync's input handling | |
| 71 | + | // (ActionRunner.cs:121-186): deprecation warnings for the inputs the step | |
| 72 | + | // gives, the defaults of the others (evaluated in the context of the step), and | |
| 73 | + | // the warning about inputs the action does not declare. The missing required | |
| 74 | + | // inputs are not checked: the runner has no such check. | |
| 75 | + | func (j *job) mergeInputs(s *stepRun, def *actionDef, in *inputSet) error { | |
| 76 | + | var act *action.Action | |
| 77 | + | if def != nil { | |
| 78 | + | act = def.act | |
| 79 | + | } | |
| 80 | + | user := append([]string(nil), in.keys...) | |
| 81 | + | if act != nil { | |
| 82 | + | for _, k := range user { | |
| 83 | + | for i := range act.Inputs { | |
| 84 | + | if act.Inputs[i].Name == k && act.Inputs[i].Deprecated() { | |
| 85 | + | j.tagged(s.num, "##[warning]", fmt.Sprintf("Input '%s' has been deprecated with message: %s", k, act.Inputs[i].DeprecationMessage)) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | valid := newInputSet() | |
| 91 | + | if def != nil && def.kind == kindDocker { | |
| 92 | + | // A container action always accepts 'entryPoint' and 'args'. | |
| 93 | + | valid.Set("entryPoint", "") | |
| 94 | + | valid.Set("args", "") | |
| 95 | + | } | |
| 96 | + | if act != nil { | |
| 97 | + | for i := range act.Inputs { | |
| 98 | + | name := act.Inputs[i].Name | |
| 99 | + | valid.Set(name, "") | |
| 100 | + | if in.Has(name) { | |
| 101 | + | continue | |
| 102 | + | } | |
| 103 | + | val, err := j.evalDefault(act.Inputs[i].Default, s.env) | |
| 104 | + | if err != nil { | |
| 105 | + | return err | |
| 106 | + | } | |
| 107 | + | in.Set(name, val) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | // Only for actions with a manifest or a repository reference (not docker://). | |
| 111 | + | if s.n.uses.Kind == action.UsesDocker { | |
| 112 | + | return nil | |
| 113 | + | } | |
| 114 | + | var unexpected []string | |
| 115 | + | for _, k := range user { | |
| 116 | + | if !valid.Has(k) { | |
| 117 | + | unexpected = append(unexpected, k) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | if len(unexpected) > 0 { | |
| 121 | + | j.tagged(s.num, "##[warning]", fmt.Sprintf("Unexpected input(s) '%s', valid inputs are ['%s']", | |
| 122 | + | strings.Join(unexpected, "', '"), strings.Join(valid.keys, "', '"))) | |
| 123 | + | } | |
| 124 | + | return nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // evalDefault is EvaluateDefaultInput: the default is a string or an expression | |
| 128 | + | // over github, strategy, matrix, job, runner and hashFiles. | |
| 129 | + | func (j *job) evalDefault(tok template.Token, env *ordered) (string, error) { | |
| 130 | + | if tok == nil { | |
| 131 | + | return "", nil | |
| 132 | + | } | |
| 133 | + | return j.evalString(tok, env) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // inputEnvName is the variable of an input (Handler.AddInputsToEnvironment). | |
| 137 | + | func inputEnvName(key string) string { | |
| 138 | + | return "INPUT_" + strings.ToUpper(strings.ReplaceAll(key, " ", "_")) | |
| 139 | + | } | |
A internal/executor/actions_job.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import "os" | |
| 6 | + | ||
| 7 | + | // prepareJob is the actions part of "Set up job" (ActionManager.PrepareActionsAsync | |
| 8 | + | // and JobExtension.cs:299-362): resolve and download the actions of the job, | |
| 9 | + | // and build the queue of the steps: the steps that pull and build images, the | |
| 10 | + | // pre stages in the order of the steps, then the steps themselves. | |
| 11 | + | func (j *job) prepareJob() ([]qstep, error) { | |
| 12 | + | j.actions = newActionState() | |
| 13 | + | // The actions of a previous job are not reused (ActionManager.cs:97-100). | |
| 14 | + | _ = os.RemoveAll(j.state.Layout().Actions) | |
| 15 | + | if err := os.MkdirAll(j.state.Layout().Actions, 0o755); err != nil { | |
| 16 | + | return nil, err | |
| 17 | + | } | |
| 18 | + | j.own(j.state.Layout().Actions) | |
| 19 | + | nodes, err := newNodes(j.jb.Steps, nil, "") | |
| 20 | + | if err != nil { | |
| 21 | + | return nil, err | |
| 22 | + | } | |
| 23 | + | j.actions.nodes = nodes | |
| 24 | + | if err := j.prepare(j.ctx(), 1, nodes, 0, nil); err != nil { | |
| 25 | + | return nil, j.setupError(err) | |
| 26 | + | } | |
| 27 | + | queue := j.setupSteps() | |
| 28 | + | for _, n := range nodes { | |
| 29 | + | if j.actions.pre[n] { | |
| 30 | + | queue = append(queue, qstep{n: n, stage: stagePre, cond: n.def.preIf()}) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | // The pre steps come before every main step: they were collected while | |
| 34 | + | // walking the steps, the mains follow. | |
| 35 | + | for _, n := range nodes { | |
| 36 | + | queue = append(queue, qstep{n: n, stage: stageMain}) | |
| 37 | + | } | |
| 38 | + | return queue, nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // setupError words an error of the preparation like the runner reports it: | |
| 42 | + | // errors of the manifests were logged already; "Failed to resolve action | |
| 43 | + | // download info." and "Failed to download archive ..." carry the inner message. | |
| 44 | + | func (j *job) setupError(err error) error { | |
| 45 | + | return err | |
| 46 | + | } | |
A internal/executor/actions_load.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-actions/action" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // loadManifest reads action.yml or action.yaml (the first is preferred) from | |
| 15 | + | // dir, like ActionManager.LoadAction (ActionManager.cs:722-731) and | |
| 16 | + | // ActionManifestManagerLegacy.Load (:53-160): every error of the manifest is | |
| 17 | + | // logged as an error of the step, then the load fails with "Failed to load | |
| 18 | + | // <path>". shown is the path the messages use: relative to the actions | |
| 19 | + | // directory for downloaded actions, the whole path for local ones | |
| 20 | + | // (ActionManifestManagerLegacy.cs:61-66). It returns (nil, "", nil) without a | |
| 21 | + | // manifest. | |
| 22 | + | func (j *job) loadManifest(num int, dir string) (*action.Action, string, error) { | |
| 23 | + | var file string | |
| 24 | + | for _, name := range []string{"action.yml", "action.yaml"} { | |
| 25 | + | if _, err := os.Stat(filepath.Join(dir, name)); err == nil { | |
| 26 | + | file = filepath.Join(dir, name) | |
| 27 | + | break | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | if file == "" { | |
| 31 | + | return nil, "", nil | |
| 32 | + | } | |
| 33 | + | shown := file | |
| 34 | + | if base := j.state.Layout().Actions; strings.HasPrefix(file, base+string(filepath.Separator)) { | |
| 35 | + | shown = file[len(base)+1:] | |
| 36 | + | } | |
| 37 | + | data, err := os.ReadFile(file) | |
| 38 | + | if err != nil { | |
| 39 | + | return nil, "", err | |
| 40 | + | } | |
| 41 | + | act, errs := action.Parse(shown, data) | |
| 42 | + | if len(errs) > 0 { | |
| 43 | + | for _, e := range errs { | |
| 44 | + | j.errorf(num, "%s", e.Error()) | |
| 45 | + | } | |
| 46 | + | return nil, "", ghErrorf("%s", action.LoadFailed(shown)) | |
| 47 | + | } | |
| 48 | + | return act, shown, nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func kindOfRuns(r *action.Runs) kind { | |
| 52 | + | switch r.Kind { | |
| 53 | + | case action.RunsNode: | |
| 54 | + | return kindNode | |
| 55 | + | case action.RunsDocker: | |
| 56 | + | return kindDocker | |
| 57 | + | case action.RunsComposite: | |
| 58 | + | return kindComposite | |
| 59 | + | } | |
| 60 | + | return kindScript | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // loadDef loads the action in dir (ActionManager.LoadAction): its manifest, or | |
| 64 | + | // a Dockerfile when there is none (Dockerfile before dockerfile). what names | |
| 65 | + | // the action in the error for a directory without any. | |
| 66 | + | func (j *job) loadDef(num int, dir string) (*actionDef, error) { | |
| 67 | + | act, _, err := j.loadManifest(num, dir) | |
| 68 | + | if err != nil { | |
| 69 | + | return nil, err | |
| 70 | + | } | |
| 71 | + | if act != nil { | |
| 72 | + | d := &actionDef{kind: kindOfRuns(act.Runs), dir: dir, act: act} | |
| 73 | + | switch act.Runs.Kind { | |
| 74 | + | case action.RunsDocker: | |
| 75 | + | d.image = act.Runs.Image | |
| 76 | + | case action.RunsPlugin: | |
| 77 | + | return nil, ghErrorf("Unsupported action type: Plugin") | |
| 78 | + | } | |
| 79 | + | return d, nil | |
| 80 | + | } | |
| 81 | + | for _, name := range []string{"Dockerfile", "dockerfile"} { | |
| 82 | + | if _, err := os.Stat(filepath.Join(dir, name)); err == nil { | |
| 83 | + | return &actionDef{kind: kindDocker, dir: dir, dockerfile: filepath.Join(dir, name)}, nil | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | return nil, errNoAction | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // errNoAction is the sentinel of a directory without manifest and Dockerfile. | |
| 90 | + | var errNoAction = fmt.Errorf("no action") | |
| 91 | + | ||
| 92 | + | // actionDir is where the action of a node lives: the workspace for a local | |
| 93 | + | // action, the download directory for a repository one (ActionManager.cs:665-694). | |
| 94 | + | func (j *job) actionDir(n *node) string { | |
| 95 | + | switch n.uses.Kind { | |
| 96 | + | case action.UsesLocal: | |
| 97 | + | return filepath.Join(j.state.GitHub("workspace"), filepath.FromSlash(strings.ReplaceAll(n.uses.Path, `\`, "/"))) | |
| 98 | + | case action.UsesRepository: | |
| 99 | + | d := filepath.Join(j.state.Layout().Actions, filepath.FromSlash(n.uses.Name), n.uses.Ref) | |
| 100 | + | if n.uses.Path != "" { | |
| 101 | + | d = filepath.Join(d, filepath.FromSlash(n.uses.Path)) | |
| 102 | + | } | |
| 103 | + | return d | |
| 104 | + | } | |
| 105 | + | return "" | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // refString is "Name[/Path]@Ref" (ActionRunner.cs:119-121, Handler.cs:130-148). | |
| 109 | + | func refString(u action.Uses) string { | |
| 110 | + | switch u.Kind { | |
| 111 | + | case action.UsesRepository: | |
| 112 | + | p := u.Name | |
| 113 | + | if u.Path != "" { | |
| 114 | + | p += "/" + u.Path | |
| 115 | + | } | |
| 116 | + | return p + "@" + u.Ref | |
| 117 | + | case action.UsesLocal: | |
| 118 | + | return u.Path | |
| 119 | + | case action.UsesDocker: | |
| 120 | + | return "docker://" + u.Image | |
| 121 | + | } | |
| 122 | + | return "" | |
| 123 | + | } | |
A internal/executor/actions_node.go +184 -0
| @@ -0,0 +1,184 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-actions/action" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | node20Name = "node20" | |
| 16 | + | node24Name = "node24" | |
| 17 | + | // Node20DeprecationURL is Constants.Runner.NodeMigration.Node20DeprecationUrl. | |
| 18 | + | node20DeprecationURL = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/" | |
| 19 | + | forceNode24Var = "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24" | |
| 20 | + | allowUnsecureVar = "ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // convertToBool is StringUtil.ConvertToBoolean with a false default. | |
| 24 | + | func convertToBool(v string) bool { | |
| 25 | + | switch strings.ToLower(v) { | |
| 26 | + | case "1", "true", "$true": | |
| 27 | + | return true | |
| 28 | + | } | |
| 29 | + | return false | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // envFlag is NodeUtil.GetEnvironmentVariableDetails: the value comes from the | |
| 33 | + | // workflow's environment (the env context of the step) when it is set there, | |
| 34 | + | // from the process of the runner otherwise. | |
| 35 | + | type envFlag struct{ on, fromWorkflow, fromSystem bool } | |
| 36 | + | ||
| 37 | + | func (j *job) nodeEnvFlag(name string, wf *ordered) envFlag { | |
| 38 | + | var f envFlag | |
| 39 | + | v, found := wf.Get(name) | |
| 40 | + | if found { | |
| 41 | + | f.fromWorkflow = true | |
| 42 | + | f.on = convertToBool(v) | |
| 43 | + | } | |
| 44 | + | sys := envValue(j.baseEnv, name) | |
| 45 | + | f.fromSystem = sys != "" | |
| 46 | + | if !found { | |
| 47 | + | f.on = convertToBool(sys) | |
| 48 | + | } | |
| 49 | + | return f | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // nodeVersionDefault is NodeUtil.DetermineActionsNodeVersion | |
| 53 | + | // (NodeUtil.cs:50-110) for an action that says node20: the version the action | |
| 54 | + | // runs on and the warning of a conflicting configuration. useNode24ByDefault is | |
| 55 | + | // the server's flag, ActionsConfig.Node24ByDefault here. | |
| 56 | + | func (j *job) nodeVersionDefault(wf *ordered) (version, warning string) { | |
| 57 | + | force := j.nodeEnvFlag(forceNode24Var, wf) | |
| 58 | + | allow := j.nodeEnvFlag(allowUnsecureVar, wf) | |
| 59 | + | byDefault := j.cfg.Actions.Node24ByDefault | |
| 60 | + | bothWorkflow := force.on && allow.on && force.fromWorkflow && allow.fromWorkflow | |
| 61 | + | bothSystem := force.on && allow.on && force.fromSystem && allow.fromSystem | |
| 62 | + | if bothWorkflow || bothSystem { | |
| 63 | + | source := "system" | |
| 64 | + | if bothWorkflow { | |
| 65 | + | source = "workflow" | |
| 66 | + | } | |
| 67 | + | def := node20Name | |
| 68 | + | if byDefault { | |
| 69 | + | def = node24Name | |
| 70 | + | } | |
| 71 | + | return def, fmt.Sprintf("Both %s and %s environment variables are set to true in the %s environment. This is likely a configuration error. Using the default Node version: %s.", | |
| 72 | + | forceNode24Var, allowUnsecureVar, source, def) | |
| 73 | + | } | |
| 74 | + | if byDefault { | |
| 75 | + | if allow.on { | |
| 76 | + | return node20Name, "" | |
| 77 | + | } | |
| 78 | + | return node24Name, "" | |
| 79 | + | } | |
| 80 | + | if force.on { | |
| 81 | + | return node24Name, "" | |
| 82 | + | } | |
| 83 | + | return node20Name, "" | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // nodeVersionFor is the node part of HandlerFactory.Create | |
| 87 | + | // (HandlerFactory.cs:56-170): node12 and node16 run on node20, node20 on | |
| 88 | + | // whatever DetermineActionsNodeVersion says, node24 on node24. The result is | |
| 89 | + | // kept in the step for the handler. | |
| 90 | + | func (j *job) nodeVersionFor(s *stepRun, def *actionDef) error { | |
| 91 | + | r := def.runs() | |
| 92 | + | if r == nil || def.kind != kindNode { | |
| 93 | + | return nil | |
| 94 | + | } | |
| 95 | + | v := r.Using | |
| 96 | + | if v == "node12" || v == "node16" { | |
| 97 | + | v = node20Name | |
| 98 | + | } | |
| 99 | + | if v == node20Name { | |
| 100 | + | final, warn := j.nodeVersionDefault(s.env) | |
| 101 | + | v = final | |
| 102 | + | if warn != "" { | |
| 103 | + | j.tagged(s.num, "##[warning]", warn) | |
| 104 | + | } | |
| 105 | + | if final == node24Name { | |
| 106 | + | j.mu.Lock() | |
| 107 | + | j.upgradedToNode24 = appendUnique(j.upgradedToNode24, actionName(s.n)) | |
| 108 | + | j.mu.Unlock() | |
| 109 | + | if j.cfg.Actions.Node24ByDefault { | |
| 110 | + | j.out(s.num, "Node 20 is being deprecated. This workflow is running with Node 24 by default. "+ | |
| 111 | + | "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. "+ | |
| 112 | + | "For more information see: "+node20DeprecationURL) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | s.nodeVersion = v | |
| 117 | + | return nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func appendUnique(l []string, v string) []string { | |
| 121 | + | if v == "" { | |
| 122 | + | return l | |
| 123 | + | } | |
| 124 | + | for _, x := range l { | |
| 125 | + | if x == v { | |
| 126 | + | return l | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | return append(l, v) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // actionName is HandlerFactory.GetActionName: "Name[/Path]@Ref". | |
| 133 | + | func actionName(n *node) string { | |
| 134 | + | if n.uses.Kind == action.UsesLocal { | |
| 135 | + | return n.uses.Path // no name: HandlerFactory.cs:200-208 uses the path alone | |
| 136 | + | } | |
| 137 | + | return registeredRef(n.uses) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // externalsDir is where the node versions live. | |
| 141 | + | func (j *job) externalsDir() string { | |
| 142 | + | if d := j.cfg.Actions.ExternalsDir; d != "" { | |
| 143 | + | return d | |
| 144 | + | } | |
| 145 | + | return defaultExternalsDir | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs: | |
| 149 | + | // 27-196) on the host of the steps (a job container is phase H): INPUT_ | |
| 150 | + | // variables and the PATH of the previous steps in the environment, the | |
| 151 | + | // script of the stage, node from the externals, run in the workspace. | |
| 152 | + | func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult { | |
| 153 | + | num := s.num | |
| 154 | + | for _, k := range in.keys { | |
| 155 | + | v, _ := in.Get(k) | |
| 156 | + | env.Set(inputEnvName(k), v) | |
| 157 | + | } | |
| 158 | + | // The PATH of the previous steps is added by procEnv. | |
| 159 | + | env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795 | |
| 160 | + | r := def.runs() | |
| 161 | + | var target string | |
| 162 | + | switch s.stage { | |
| 163 | + | case stageMain: | |
| 164 | + | target = r.Main | |
| 165 | + | case stagePre: | |
| 166 | + | target = r.Pre | |
| 167 | + | case stagePost: | |
| 168 | + | target = r.Post | |
| 169 | + | } | |
| 170 | + | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | |
| 171 | + | if target == "" { | |
| 172 | + | return fail(ghErrorf("Value cannot be null or empty. (Parameter 'target')")) | |
| 173 | + | } | |
| 174 | + | script := filepath.Join(def.dir, filepath.FromSlash(target)) | |
| 175 | + | if st, err := os.Stat(script); err != nil || st.IsDir() { | |
| 176 | + | return fail(ghErrorf("File not found: '%s'", script)) | |
| 177 | + | } | |
| 178 | + | j.mu.Lock() | |
| 179 | + | wd := j.state.GitHub("workspace") | |
| 180 | + | j.mu.Unlock() | |
| 181 | + | node := filepath.Join(j.externalsDir(), s.nodeVersion, "bin", "node") | |
| 182 | + | s.actEnv = env | |
| 183 | + | return j.runProc(s, node, wd, []string{node, script}, "Node Action run completed with exit code %d", cancel) | |
| 184 | + | } | |
A internal/executor/actions_prepare.go +322 -0
| @@ -0,0 +1,322 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite-actions/action" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // actionState is what the pre-job phase leaves for the run. | |
| 13 | + | type actionState struct { | |
| 14 | + | nodes []*node | |
| 15 | + | // pulled and built map an image to what the setup steps produced. | |
| 16 | + | pulled map[string]string // image -> image | |
| 17 | + | built map[string]string // action repository -> tag | |
| 18 | + | // setup are the steps that pull and build images, in the order of | |
| 19 | + | // ActionManager.PrepareActionsAsync (pulls, then builds). | |
| 20 | + | pullOrder, buildOrder []string | |
| 21 | + | buildInfo map[string]buildInfo | |
| 22 | + | pullUsers map[string][]*node | |
| 23 | + | buildUsers map[string][]*node | |
| 24 | + | // pre are the pre steps of the job's steps with a pre stage, by node. | |
| 25 | + | pre map[*node]bool | |
| 26 | + | // resolved caches the download info by "name@ref" (ActionManager's legacy | |
| 27 | + | // path asks again at every level; ermite's server caches per run). | |
| 28 | + | downloaded map[string]bool | |
| 29 | + | // infos are the resolutions of the job by "Name@Ref": an action that a local | |
| 30 | + | // composite action brings again is not asked for again. | |
| 31 | + | infos map[string]ResolvedAction | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type buildInfo struct{ repository, dockerfile, workDir string } | |
| 35 | + | ||
| 36 | + | func newActionState() *actionState { | |
| 37 | + | return &actionState{ | |
| 38 | + | pulled: map[string]string{}, built: map[string]string{}, buildInfo: map[string]buildInfo{}, | |
| 39 | + | pullUsers: map[string][]*node{}, buildUsers: map[string][]*node{}, pre: map[*node]bool{}, | |
| 40 | + | downloaded: map[string]bool{}, infos: map[string]ResolvedAction{}, | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // lookupKey is ActionManager.GetDownloadInfoLookupKey: "Name@Ref" for a | |
| 45 | + | // repository action, "" for a local one. | |
| 46 | + | func lookupKey(n *node) (string, error) { | |
| 47 | + | switch n.uses.Kind { | |
| 48 | + | case action.UsesRepository: | |
| 49 | + | return n.uses.Name + "@" + n.uses.Ref, nil | |
| 50 | + | case action.UsesSelfRepository: | |
| 51 | + | return "", ghErrorf("Unable to resolve self-reference '$/'. This can occur when the server does not support this syntax, the feature flag is disabled, or the workflow context (repository/SHA) is unavailable.") | |
| 52 | + | } | |
| 53 | + | return "", nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // prepare is ActionManager.PrepareActionsRecursiveLegacyAsync | |
| 57 | + | // (ActionManager.cs:439-654): resolve and download the repository actions of | |
| 58 | + | // nodes, read their manifests, recurse into composite actions, and note the | |
| 59 | + | // images to pull or build and the pre and post stages. depth is 0 for the | |
| 60 | + | // steps of the job and 1 for the ones of a local composite action prepared when | |
| 61 | + | // it runs. | |
| 62 | + | func (j *job) prepare(ctx context.Context, num int, nodes []*node, depth int, parent *node) error { | |
| 63 | + | if depth > compositeMaxDepth { | |
| 64 | + | return ghErrorf("Composite action depth exceeded max depth %d", compositeMaxDepth) | |
| 65 | + | } | |
| 66 | + | st := j.actions | |
| 67 | + | var repoNodes []*node | |
| 68 | + | for _, n := range nodes { | |
| 69 | + | switch n.uses.Kind { | |
| 70 | + | case action.UsesDocker: | |
| 71 | + | if n.uses.Image == "" { | |
| 72 | + | return ghErrorf("Value cannot be null or empty. (Parameter 'Image')") | |
| 73 | + | } | |
| 74 | + | if _, ok := st.pullUsers[n.uses.Image]; !ok { | |
| 75 | + | st.pullOrder = append(st.pullOrder, n.uses.Image) | |
| 76 | + | } | |
| 77 | + | st.pullUsers[n.uses.Image] = append(st.pullUsers[n.uses.Image], n) | |
| 78 | + | case action.UsesRepository, action.UsesLocal, action.UsesSelfRepository: | |
| 79 | + | repoNodes = append(repoNodes, n) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | if len(repoNodes) == 0 { | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | infos, err := j.downloadInfo(ctx, num, repoNodes) | |
| 86 | + | if err != nil { | |
| 87 | + | return err | |
| 88 | + | } | |
| 89 | + | for _, n := range repoNodes { | |
| 90 | + | key, err := lookupKey(n) | |
| 91 | + | if err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | if key == "" { | |
| 95 | + | continue | |
| 96 | + | } | |
| 97 | + | ra, ok := infos[key] | |
| 98 | + | if !ok { | |
| 99 | + | return ghErrorf("Missing download info for %s", key) | |
| 100 | + | } | |
| 101 | + | dir := j.actionDirOfRepo(n) | |
| 102 | + | // The directory of the archive is the one of the repository itself: the | |
| 103 | + | // action's path is below it. | |
| 104 | + | root := j.state.Layout().Actions + "/" + n.uses.Name + "/" + n.uses.Ref | |
| 105 | + | _ = dir | |
| 106 | + | if err := j.downloadAction(ctx, num, ra, ra.NameWithOwner, n.uses.Ref, root); err != nil { | |
| 107 | + | return err | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | for _, n := range repoNodes { | |
| 111 | + | if err := j.prepareRepositoryAction(ctx, num, n, depth); err != nil { | |
| 112 | + | return err | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | return nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (j *job) actionDirOfRepo(n *node) string { return j.actionDir(n) } | |
| 119 | + | ||
| 120 | + | // downloadInfo is ActionManager.GetDownloadInfoAsync (ActionManager.cs:1000-1135): | |
| 121 | + | // it asks the server, through the host, about the distinct repository actions | |
| 122 | + | // of nodes. The server's answer for one action may be an error in GitHub's | |
| 123 | + | // words, which is the message of the failure. | |
| 124 | + | func (j *job) downloadInfo(ctx context.Context, num int, nodes []*node) (map[string]ResolvedAction, error) { | |
| 125 | + | j.out(num, "Getting action download info") | |
| 126 | + | var uses, keys []string | |
| 127 | + | seen := map[string]bool{} | |
| 128 | + | for _, n := range nodes { | |
| 129 | + | key, err := lookupKey(n) | |
| 130 | + | if err != nil { | |
| 131 | + | return nil, err | |
| 132 | + | } | |
| 133 | + | if key == "" || seen[key] { | |
| 134 | + | continue | |
| 135 | + | } | |
| 136 | + | seen[key] = true | |
| 137 | + | keys = append(keys, key) | |
| 138 | + | uses = append(uses, refString(n.uses)) | |
| 139 | + | } | |
| 140 | + | out := map[string]ResolvedAction{} | |
| 141 | + | if len(uses) == 0 { | |
| 142 | + | return out, nil | |
| 143 | + | } | |
| 144 | + | // What the job resolved already is not asked again: the host would not call | |
| 145 | + | // the server for it either (host/resolve.go), but the round trip is saved. | |
| 146 | + | var askUses, askKeys []string | |
| 147 | + | for i, k := range keys { | |
| 148 | + | if ra, ok := j.actions.infos[k]; ok { | |
| 149 | + | out[k] = ra | |
| 150 | + | continue | |
| 151 | + | } | |
| 152 | + | askUses, askKeys = append(askUses, uses[i]), append(askKeys, k) | |
| 153 | + | } | |
| 154 | + | if len(askUses) == 0 { | |
| 155 | + | return out, nil | |
| 156 | + | } | |
| 157 | + | uses, keys = askUses, askKeys | |
| 158 | + | if j.cfg.Actions.Resolver == nil { | |
| 159 | + | return nil, ghErrorf("Failed to resolve action download info.") | |
| 160 | + | } | |
| 161 | + | res, err := j.cfg.Actions.Resolver(ctx, uses) | |
| 162 | + | if err != nil { | |
| 163 | + | return nil, ghErrorf("%s", err.Error()) | |
| 164 | + | } | |
| 165 | + | if len(res) != len(uses) { | |
| 166 | + | return nil, ghErrorf("Failed to resolve action download info.") | |
| 167 | + | } | |
| 168 | + | for i, ra := range res { | |
| 169 | + | if ra.Error != "" { | |
| 170 | + | return nil, ghErrorf("%s", ra.Error) | |
| 171 | + | } | |
| 172 | + | // The token is a secret from here on (ActionManager.cs:1130). | |
| 173 | + | j.addTokenMask(ra.Token) | |
| 174 | + | if ra.NameWithOwner == "" { | |
| 175 | + | ra.NameWithOwner = strings.SplitN(keys[i], "@", 2)[0] | |
| 176 | + | } | |
| 177 | + | out[keys[i]] = ra | |
| 178 | + | j.actions.infos[keys[i]] = ra | |
| 179 | + | } | |
| 180 | + | return out, nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // prepareRepositoryAction is ActionManager.PrepareRepositoryActionAsync | |
| 184 | + | // (:1411-1550) and the code after it in the loop of the caller: read the | |
| 185 | + | // manifest, note an image to pull or build, recurse into a composite, and | |
| 186 | + | // register pre and post stages. | |
| 187 | + | func (j *job) prepareRepositoryAction(ctx context.Context, num int, n *node, depth int) error { | |
| 188 | + | local := n.uses.Kind == action.UsesLocal | |
| 189 | + | if local { | |
| 190 | + | if depth > 0 { | |
| 191 | + | // "if we're in a composite action and haven't loaded the local action | |
| 192 | + | // yet we assume it has a post step" (ActionManager.cs:643-654). | |
| 193 | + | n.parentPost() | |
| 194 | + | } | |
| 195 | + | return nil | |
| 196 | + | } | |
| 197 | + | dir := j.actionDir(n) | |
| 198 | + | def, err := j.loadDef(num, dir) | |
| 199 | + | if err == errNoAction { | |
| 200 | + | return ghErrorf("Can't find 'action.yml', 'action.yaml' or 'Dockerfile' for action '%s'.", refString(n.uses)) | |
| 201 | + | } | |
| 202 | + | if err != nil { | |
| 203 | + | return err | |
| 204 | + | } | |
| 205 | + | n.def = def | |
| 206 | + | repo := n.uses.Name + "@" + n.uses.Ref | |
| 207 | + | if n.uses.Path != "" { | |
| 208 | + | repo = n.uses.Name + "/" + n.uses.Path + "@" + n.uses.Ref | |
| 209 | + | } | |
| 210 | + | root := j.state.Layout().Actions + "/" + n.uses.Name + "/" + n.uses.Ref | |
| 211 | + | st := j.actions | |
| 212 | + | switch def.kind { | |
| 213 | + | case kindDocker: | |
| 214 | + | img, build, err := j.dockerImageOf(def) | |
| 215 | + | if err != nil { | |
| 216 | + | return err | |
| 217 | + | } | |
| 218 | + | if build != "" { | |
| 219 | + | if _, ok := st.buildUsers[repo]; !ok { | |
| 220 | + | st.buildOrder = append(st.buildOrder, repo) | |
| 221 | + | } | |
| 222 | + | st.buildUsers[repo] = append(st.buildUsers[repo], n) | |
| 223 | + | st.buildInfo[repo] = buildInfo{repository: repo, dockerfile: build, workDir: root} | |
| 224 | + | } else { | |
| 225 | + | if _, ok := st.pullUsers[img]; !ok { | |
| 226 | + | st.pullOrder = append(st.pullOrder, img) | |
| 227 | + | } | |
| 228 | + | st.pullUsers[img] = append(st.pullUsers[img], n) | |
| 229 | + | } | |
| 230 | + | case kindComposite: | |
| 231 | + | if err := j.prepareComposite(ctx, num, n, depth); err != nil { | |
| 232 | + | return err | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | j.registerStages(n, depth) | |
| 236 | + | return nil | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // dockerImageOf says what a container action needs: an image to pull, or a | |
| 240 | + | // Dockerfile to build (absolute path). | |
| 241 | + | func (j *job) dockerImageOf(def *actionDef) (pull, build string, err error) { | |
| 242 | + | if def.act == nil { | |
| 243 | + | return "", def.dockerfile, nil | |
| 244 | + | } | |
| 245 | + | im, err := action.ParseImage(def.image) | |
| 246 | + | if err != nil { | |
| 247 | + | return "", "", err | |
| 248 | + | } | |
| 249 | + | if im.Kind == action.ImageDockerfile { | |
| 250 | + | return "", def.dir + "/" + im.Path, nil | |
| 251 | + | } | |
| 252 | + | return im.Ref, "", nil | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // prepareComposite creates the nodes of a composite action's steps and prepares | |
| 256 | + | // them one level down. | |
| 257 | + | func (j *job) prepareComposite(ctx context.Context, num int, n *node, depth int) error { | |
| 258 | + | steps := n.def.act.Runs.Steps | |
| 259 | + | kids, err := newNodes(steps, n, n.scope) | |
| 260 | + | if err != nil { | |
| 261 | + | return err | |
| 262 | + | } | |
| 263 | + | n.children = kids | |
| 264 | + | return j.prepare(ctx, num, kids, depth+1, n) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // registerStages notes the pre and post stages of a prepared action that is not | |
| 268 | + | // local (ActionManager.cs:600-640): for the steps of the job a pre step, for | |
| 269 | + | // the steps of a composite action a pre and post step of the composite. | |
| 270 | + | func (j *job) registerStages(n *node, depth int) { | |
| 271 | + | pre, post := n.hasPre(), n.hasPost() | |
| 272 | + | if depth < 1 { | |
| 273 | + | if pre { | |
| 274 | + | j.actions.pre[n] = true | |
| 275 | + | } | |
| 276 | + | return | |
| 277 | + | } | |
| 278 | + | if p := n.parent; p != nil { | |
| 279 | + | if pre { | |
| 280 | + | p.preSteps = append(p.preSteps, n) | |
| 281 | + | } | |
| 282 | + | if post { | |
| 283 | + | p.postSteps = append(p.postSteps, n) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // parentPost is the assumption for a local action inside a composite action. | |
| 289 | + | func (n *node) parentPost() { | |
| 290 | + | if p := n.parent; p != nil { | |
| 291 | + | p.postSteps = append(p.postSteps, n) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // hasPre and hasPost are ActionExecutionData.HasPre and HasPost. | |
| 296 | + | func (n *node) hasPre() bool { | |
| 297 | + | d := n.def | |
| 298 | + | if d == nil { | |
| 299 | + | return false | |
| 300 | + | } | |
| 301 | + | if d.kind == kindComposite { | |
| 302 | + | return len(n.preSteps) > 0 | |
| 303 | + | } | |
| 304 | + | if r := d.runs(); r != nil { | |
| 305 | + | return r.HasPre() | |
| 306 | + | } | |
| 307 | + | return false | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func (n *node) hasPost() bool { | |
| 311 | + | d := n.def | |
| 312 | + | if d == nil { | |
| 313 | + | return false | |
| 314 | + | } | |
| 315 | + | if d.kind == kindComposite { | |
| 316 | + | return len(n.postSteps) > 0 | |
| 317 | + | } | |
| 318 | + | if r := d.runs(); r != nil { | |
| 319 | + | return r.HasPost() | |
| 320 | + | } | |
| 321 | + | return false | |
| 322 | + | } | |
A internal/executor/actions_results_test.go +298 -0
| @@ -0,0 +1,298 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/base64" | |
| 7 | + | "fmt" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // actionResults runs a workflow of actions with a recorder that keeps the | |
| 14 | + | // results of the steps (annotations, summaries, outcomes). | |
| 15 | + | func actionResults(t *testing.T, tree files, yaml string, tweak func(*Config)) (*resultRecorder, Result) { | |
| 16 | + | t.Helper() | |
| 17 | + | needTools(t, "bash") | |
| 18 | + | rr := newResultRecorder() | |
| 19 | + | _, res := runLocal(t, wfHead+" steps:\n"+yaml, "j", func(c *Config) { | |
| 20 | + | writeFiles(t, c.Layout.Workspace, tree) | |
| 21 | + | c.Actions.ExternalsDir = fakeNode(t) | |
| 22 | + | c.Reporter = rr | |
| 23 | + | c.SendAnnotations, c.SendSummaries = true, true | |
| 24 | + | if tweak != nil { | |
| 25 | + | tweak(c) | |
| 26 | + | } | |
| 27 | + | }) | |
| 28 | + | return rr, res | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func countLevel(as []Annotation, l AnnotationLevel) int { | |
| 32 | + | n := 0 | |
| 33 | + | for _, a := range as { | |
| 34 | + | if a.Level == l { | |
| 35 | + | n++ | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | return n | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Commands from a node action: annotations, masks, a problem matcher, the step | |
| 42 | + | // summary and the saved state work as in a run step. | |
| 43 | + | func TestNodeActionCommands(t *testing.T) { | |
| 44 | + | tree := files{ | |
| 45 | + | "n/action.yml": "name: n\nruns:\n using: node20\n main: main.sh\n", | |
| 46 | + | "n/main.sh": `echo "::error title=T,file=a.txt,line=3::broken" | |
| 47 | + | echo "::add-mask::hunter2" | |
| 48 | + | echo "the password is hunter2" | |
| 49 | + | echo "::warning::careful" | |
| 50 | + | echo "# Summary hunter2" >> "$GITHUB_STEP_SUMMARY" | |
| 51 | + | echo '{"problemMatcher":[{"owner":"m","pattern":[{"regexp":"^bad: (.+)$","message":1}]}]}' > "$RUNNER_TEMP/m.json" | |
| 52 | + | echo "::add-matcher::$RUNNER_TEMP/m.json" | |
| 53 | + | echo "bad: from the matcher" | |
| 54 | + | `, | |
| 55 | + | } | |
| 56 | + | rr, res := actionResults(t, tree, " - uses: ./n\n", nil) | |
| 57 | + | if res.Conclusion != ConclusionSuccess { | |
| 58 | + | t.Fatalf("%+v\n%s", res, logOf(rr.steps[1])) | |
| 59 | + | } | |
| 60 | + | log := logOf(rr.step(t, "Run ./n")) | |
| 61 | + | if strings.Contains(log, "hunter2") || !strings.Contains(log, "the password is ***") { | |
| 62 | + | t.Errorf("add-mask:\n%s", log) | |
| 63 | + | } | |
| 64 | + | as := rr.results[2].Annotations | |
| 65 | + | if len(as) != 3 || as[0].Level != LevelFailure || as[0].Title != "T" || as[0].File != "a.txt" || as[0].StartLine != 3 || | |
| 66 | + | as[1].Level != LevelWarning || as[2].Level != LevelFailure || as[2].Message != "from the matcher" { | |
| 67 | + | t.Errorf("annotations %+v", as) | |
| 68 | + | } | |
| 69 | + | if got := rr.results[2].Summary; got != "# Summary ***\n" { | |
| 70 | + | t.Errorf("summary %q", got) | |
| 71 | + | } | |
| 72 | + | // An ::error:: fails the step only through its exit code, not by itself, but | |
| 73 | + | // the command result merges: the step succeeds here, as the script exited 0. | |
| 74 | + | if rr.step(t, "Run ./n").Conclusion != ConclusionSuccess { | |
| 75 | + | t.Errorf("conclusion %s", rr.step(t, "Run ./n").Conclusion) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // The state of an action is kept under one key for its pre, main and post stages; | |
| 80 | + | // the first spelling of a name wins and entries come in the order they were saved. | |
| 81 | + | // (The runner never runs pre of a local action, so this one is a repository action.) | |
| 82 | + | func TestActionStateKeepsFirstSpellingAndOrder(t *testing.T) { | |
| 83 | + | forge := newForge(t, map[string][]byte{"/tarball/o/p/v1": tarOf(t, "o-p-x", files{ | |
| 84 | + | "action.yml": "name: p\nruns:\n using: node20\n pre: pre.sh\n main: main.sh\n post: post.sh\n", | |
| 85 | + | "pre.sh": "echo \"Zed=1\" >> \"$GITHUB_STATE\"\necho \"alpha=2\" >> \"$GITHUB_STATE\"\n", | |
| 86 | + | "main.sh": "echo \"ZED=3\" >> \"$GITHUB_STATE\"\necho \"main: $(env | grep '^STATE_' | sort | tr '\\n' ' ')\"\n", | |
| 87 | + | "post.sh": "echo \"post: $(env | grep '^STATE_' | sort | tr '\\n' ' ')\"\n", | |
| 88 | + | })}) | |
| 89 | + | rr, res := actionResults(t, files{}, " - uses: o/p@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") }) | |
| 90 | + | if res.Conclusion != ConclusionSuccess { | |
| 91 | + | t.Fatalf("%+v", res) | |
| 92 | + | } | |
| 93 | + | if got := logOf(rr.step(t, "Run o/p@v1")); !strings.Contains(got, "main: STATE_Zed=1 STATE_alpha=2 ") { | |
| 94 | + | t.Errorf("main:\n%s", got) | |
| 95 | + | } | |
| 96 | + | if got := logOf(rr.step(t, "Post Run o/p@v1")); !strings.Contains(got, "post: STATE_Zed=3 STATE_alpha=2 ") { | |
| 97 | + | t.Errorf("post:\n%s", got) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Two uses of one action have two states, and the ::save-state:: command and the | |
| 102 | + | // file end in the same one. | |
| 103 | + | func TestActionStateIsPerStep(t *testing.T) { | |
| 104 | + | forge := newForge(t, map[string][]byte{"/tarball/o/q/v1": tarOf(t, "o-q-x", files{ | |
| 105 | + | "action.yml": "name: q\ninputs:\n v:\n default: d\nruns:\n using: node20\n main: main.sh\n post: post.sh\n", | |
| 106 | + | "main.sh": "echo \"::save-state name=v::$INPUT_V\"\necho \"f=$INPUT_V\" >> \"$GITHUB_STATE\"\n", | |
| 107 | + | "post.sh": "echo \"post sees v=$STATE_v f=$STATE_f\"\n", | |
| 108 | + | })}) | |
| 109 | + | rr, res := actionResults(t, files{}, " - uses: o/q@v1\n with:\n v: one\n - uses: o/q@v1\n with:\n v: two\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") }) | |
| 110 | + | if res.Conclusion != ConclusionSuccess { | |
| 111 | + | t.Fatalf("%+v", res) | |
| 112 | + | } | |
| 113 | + | var posts []string | |
| 114 | + | for _, s := range rr.steps { | |
| 115 | + | if strings.HasPrefix(s.Name, "Post ") { | |
| 116 | + | posts = append(posts, logOf(s)) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | if len(posts) != 2 || !strings.Contains(posts[0], "post sees v=two f=two") || !strings.Contains(posts[1], "post sees v=one f=one") { | |
| 120 | + | t.Errorf("%q", posts) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // ::error:: of a node action and of the inner steps of a composite land on the | |
| 125 | + | // step of the workflow; the limit of 10 per level counts every inner step. | |
| 126 | + | func TestCompositeAnnotationsAreCountedOnTheOuterStep(t *testing.T) { | |
| 127 | + | inner := func(n int, level string) string { | |
| 128 | + | return " run: |\n for i in $(seq 1 " + fmt.Sprint(n) + "); do echo \"::" + level + "::" + level + " $i\"; done\n" | |
| 129 | + | } | |
| 130 | + | tree := files{ | |
| 131 | + | "c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - shell: bash\n" + inner(8, "error") + | |
| 132 | + | " - shell: bash\n" + inner(8, "error") + " - shell: bash\n" + inner(3, "warning") + " - uses: ./n\n", | |
| 133 | + | "n/action.yml": "name: n\nruns:\n using: node20\n main: main.sh\n", | |
| 134 | + | "n/main.sh": "echo '::notice::from the node action inside'\n", | |
| 135 | + | } | |
| 136 | + | rr, res := actionResults(t, tree, " - uses: ./c\n - run: echo \"::error::the next step\"\n", nil) | |
| 137 | + | if res.Conclusion != ConclusionSuccess { | |
| 138 | + | t.Fatalf("%+v", res) | |
| 139 | + | } | |
| 140 | + | as := rr.results[2].Annotations | |
| 141 | + | if countLevel(as, LevelFailure) != 10 || countLevel(as, LevelWarning) != 3 || countLevel(as, LevelNotice) != 1 { | |
| 142 | + | t.Errorf("failure %d (want 10 of 16), warning %d, notice %d: %+v", countLevel(as, LevelFailure), countLevel(as, LevelWarning), countLevel(as, LevelNotice), as) | |
| 143 | + | } | |
| 144 | + | if as[0].Message != "error 1" { | |
| 145 | + | t.Errorf("the first ones are kept: %+v", as[0]) | |
| 146 | + | } | |
| 147 | + | // Nothing leaks to the step after, and the inner steps have no number of their own. | |
| 148 | + | if got := rr.results[3].Annotations; len(got) != 1 || got[0].Message != "the next step" { | |
| 149 | + | t.Errorf("next step %+v", got) | |
| 150 | + | } | |
| 151 | + | // A local action inside a composite is assumed to have a post step, so the | |
| 152 | + | // composite has one too (ActionManager.cs:643-654): "Post Run ./c" is empty. | |
| 153 | + | if got := strings.Join(rr.names(), "|"); got != "Set up job|Run ./c|Run echo \"::error::the next step\"|Post Run ./c|Complete job" { | |
| 154 | + | t.Errorf("steps %v", rr.names()) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // The outer step's own issues come first: a composite whose inner steps have used | |
| 159 | + | // the 10 slots of "failure" cannot push out the error the runner logs for the | |
| 160 | + | // outer step itself... in the runner's order, which is chronological. | |
| 161 | + | func TestCompositeSummaryAndMaskBelongToTheOuterStep(t *testing.T) { | |
| 162 | + | tree := files{"c/action.yml": `name: c | |
| 163 | + | runs: | |
| 164 | + | using: composite | |
| 165 | + | steps: | |
| 166 | + | - shell: bash | |
| 167 | + | run: | | |
| 168 | + | echo "::add-mask::inner-secret" | |
| 169 | + | echo "first" >> "$GITHUB_STEP_SUMMARY" | |
| 170 | + | - shell: bash | |
| 171 | + | run: | | |
| 172 | + | echo "second inner-secret" >> "$GITHUB_STEP_SUMMARY" | |
| 173 | + | echo "visible: inner-secret" | |
| 174 | + | `} | |
| 175 | + | rr, res := actionResults(t, tree, " - uses: ./c\n - run: echo \"after - inner-secret\"\n", nil) | |
| 176 | + | if res.Conclusion != ConclusionSuccess { | |
| 177 | + | t.Fatalf("%+v", res) | |
| 178 | + | } | |
| 179 | + | if got := rr.results[2].Summary; got != "first\nsecond ***\n" { | |
| 180 | + | t.Errorf("summary %q", got) | |
| 181 | + | } | |
| 182 | + | if got := logOf(rr.steps[1]) + logOf(rr.steps[2]); strings.Contains(got, "inner-secret\n") && !strings.Contains(got, "***") || strings.Contains(strings.ReplaceAll(got, "::add-mask::inner-secret", ""), "visible: inner-secret") || strings.Contains(logOf(rr.steps[2]), "after - inner-secret") { | |
| 183 | + | t.Errorf("a mask added by an inner step must hide the value everywhere:\n%s", got) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // The archive token, and its Basic form, are masked wherever they appear. | |
| 188 | + | func TestArchiveTokenAndBasicFormAreMasked(t *testing.T) { | |
| 189 | + | const token = "archive-tok-123" | |
| 190 | + | basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token)) | |
| 191 | + | forge := newForge(t, map[string][]byte{"/tarball/o/t/v1": tarOf(t, "o-t-x", files{ | |
| 192 | + | "action.yml": "name: t\nruns:\n using: node20\n main: main.sh\n", | |
| 193 | + | "main.sh": "echo \"token " + token + " basic " + basic + "\"\n"})}) | |
| 194 | + | rr, res := actionResults(t, files{}, " - uses: o/t@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver(token) }) | |
| 195 | + | if res.Conclusion != ConclusionSuccess { | |
| 196 | + | t.Fatalf("%+v", res) | |
| 197 | + | } | |
| 198 | + | if got := logOf(rr.step(t, "Run o/t@v1")); !strings.Contains(got, "token *** basic ***") { | |
| 199 | + | t.Errorf("%s", got) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // timeout-minutes on an action step, on a docker step and on an inner step of a | |
| 204 | + | // composite: the step fails with the runner's message and the job goes on. | |
| 205 | + | func TestActionStepTimeouts(t *testing.T) { | |
| 206 | + | skew := -(60*time.Second - 1500*time.Millisecond) | |
| 207 | + | tree := files{ | |
| 208 | + | "slow/action.yml": "name: slow\nruns:\n using: node20\n main: main.sh\n", | |
| 209 | + | "slow/main.sh": "sleep 30\n", | |
| 210 | + | "c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - name: inner\n shell: bash\n run: sleep 30\n - shell: bash\n if: always()\n run: echo inner-after\n", | |
| 211 | + | } | |
| 212 | + | start := time.Now() | |
| 213 | + | rr, res := actionResults(t, tree, ` - name: node | |
| 214 | + | timeout-minutes: 1 | |
| 215 | + | uses: ./slow | |
| 216 | + | - name: after | |
| 217 | + | if: always() | |
| 218 | + | run: echo after-ran | |
| 219 | + | `, func(c *Config) { c.StepTimeoutSkew = skew }) | |
| 220 | + | if res.Conclusion != ConclusionFailure { | |
| 221 | + | t.Fatalf("%+v", res) | |
| 222 | + | } | |
| 223 | + | if time.Since(start) > 10*time.Second { | |
| 224 | + | t.Errorf("the timeout did not stop the action: %v", time.Since(start)) | |
| 225 | + | } | |
| 226 | + | n := rr.step(t, "node") | |
| 227 | + | if n.Conclusion != ConclusionFailure || !strings.Contains(logOf(n), "##[error]The action 'node' has timed out after 1 minutes.") || strings.Contains(logOf(n), "The operation was canceled.") { | |
| 228 | + | t.Errorf("%s\n%s", n.Conclusion, logOf(n)) | |
| 229 | + | } | |
| 230 | + | if rr.step(t, "after").Conclusion != ConclusionSuccess { | |
| 231 | + | t.Error("the job is not cancelled by a step timeout") | |
| 232 | + | } | |
| 233 | + | // The inner step of a composite action. | |
| 234 | + | start = time.Now() | |
| 235 | + | rr, res = actionResults(t, tree, " - name: comp\n timeout-minutes: 1\n uses: ./c\n", func(c *Config) { c.StepTimeoutSkew = skew }) | |
| 236 | + | if res.Conclusion != ConclusionFailure || time.Since(start) > 10*time.Second { | |
| 237 | + | t.Fatalf("%+v %v", res, time.Since(start)) | |
| 238 | + | } | |
| 239 | + | log := logOf(rr.step(t, "comp")) | |
| 240 | + | // The inner step has what is left of the timeout of the step that uses the | |
| 241 | + | // action and fails with a message that names no step (the runner's composite | |
| 242 | + | // handler). The time is spent after it: the steps that follow are cancelled at | |
| 243 | + | // once, as the runner's are (their token is cancelled when they are created). | |
| 244 | + | if strings.Count(log, "##[error]The action has timed out.") < 1 || strings.Contains(log, "after 0 minutes") || strings.Contains(log, "The operation was canceled.") { | |
| 245 | + | t.Errorf("%s", log) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // A docker action's output goes through the same commands: annotations, masks, | |
| 250 | + | // matchers. | |
| 251 | + | func TestDockerActionCommands(t *testing.T) { | |
| 252 | + | dir := t.TempDir() | |
| 253 | + | script := `#!/bin/sh | |
| 254 | + | case "$1" in | |
| 255 | + | run) | |
| 256 | + | echo "::add-mask::dkr-secret" | |
| 257 | + | echo "value dkr-secret" | |
| 258 | + | echo "::error file=x.go,line=2::from docker" | |
| 259 | + | echo "::group::g" | |
| 260 | + | echo "::endgroup::" | |
| 261 | + | ;; | |
| 262 | + | esac | |
| 263 | + | exit 0 | |
| 264 | + | ` | |
| 265 | + | writeFiles(t, dir, files{"docker": script}) | |
| 266 | + | rr, res := actionResults(t, files{}, " - uses: docker://alpine:3\n", func(c *Config) { c.Actions.DockerPath = dir + "/docker" }) | |
| 267 | + | if res.Conclusion != ConclusionSuccess { | |
| 268 | + | t.Fatalf("%+v", res) | |
| 269 | + | } | |
| 270 | + | log := logOf(rr.step(t, "Run alpine:3")) | |
| 271 | + | if strings.Contains(log, "value dkr-secret") || !strings.Contains(log, "value ***") { | |
| 272 | + | t.Errorf("%s", log) | |
| 273 | + | } | |
| 274 | + | if as := rr.results[3].Annotations; len(as) != 1 || as[0].Message != "from docker" || as[0].File != "x.go" || as[0].StartLine != 2 { | |
| 275 | + | t.Errorf("annotations %+v", rr.results) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // The outcome of an action step, before continue-on-error, reaches the reporter. | |
| 280 | + | func TestActionOutcomeIsReported(t *testing.T) { | |
| 281 | + | tree := files{"f/action.yml": "name: f\nruns:\n using: node20\n main: main.sh\n", "f/main.sh": "exit 1\n"} | |
| 282 | + | rr, res := actionResults(t, tree, " - uses: ./f\n continue-on-error: true\n", nil) | |
| 283 | + | if res.Conclusion != ConclusionSuccess { | |
| 284 | + | t.Fatalf("%+v", res) | |
| 285 | + | } | |
| 286 | + | if got := rr.results[2].Outcome; got != ConclusionFailure { | |
| 287 | + | t.Errorf("outcome %q, want failure", got) | |
| 288 | + | } | |
| 289 | + | if rr.step(t, "Run ./f").Conclusion != ConclusionSuccess { | |
| 290 | + | t.Error("continue-on-error") | |
| 291 | + | } | |
| 292 | + | // A failing action says why in an annotation too: the exit code is an error of | |
| 293 | + | // the runner, which GitHub reports as an annotation for run steps; node | |
| 294 | + | // actions only mark the step failed. | |
| 295 | + | if as := rr.results[2].Annotations; len(as) != 0 { | |
| 296 | + | t.Errorf("annotations %+v", as) | |
| 297 | + | } | |
| 298 | + | } | |
A internal/executor/actions_run.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-actions/action" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // stageName is for messages. | |
| 14 | + | func (s stage) String() string { | |
| 15 | + | switch s { | |
| 16 | + | case stagePre: | |
| 17 | + | return "Pre" | |
| 18 | + | case stagePost: | |
| 19 | + | return "Post" | |
| 20 | + | } | |
| 21 | + | return "Main" | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // definition is ActionManager.LoadAction for the step: the manifest of a local | |
| 25 | + | // action is read when the step runs; the other actions were loaded by the | |
| 26 | + | // pre-job phase. | |
| 27 | + | func (j *job) definition(s *stepRun) (*actionDef, error) { | |
| 28 | + | n := s.n | |
| 29 | + | switch n.uses.Kind { | |
| 30 | + | case action.UsesDocker: | |
| 31 | + | return &actionDef{kind: kindDocker, image: n.uses.Image}, nil | |
| 32 | + | case action.UsesLocal: | |
| 33 | + | dir := j.actionDir(n) | |
| 34 | + | def, err := j.loadDef(s.num, dir) | |
| 35 | + | if err == errNoAction { | |
| 36 | + | return nil, ghErrorf("Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '%s'. Did you forget to run actions/checkout before running your local action?", filepath.Clean(dir)) | |
| 37 | + | } | |
| 38 | + | if err != nil { | |
| 39 | + | return nil, err | |
| 40 | + | } | |
| 41 | + | n.def = def | |
| 42 | + | return def, nil | |
| 43 | + | } | |
| 44 | + | if n.def == nil { | |
| 45 | + | return nil, ghErrorf("The action '%s' was not prepared", refString(n.uses)) | |
| 46 | + | } | |
| 47 | + | return n.def, nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // setActionRepository sets github.action_repository and action_ref | |
| 51 | + | // (ActionRunner.cs:149-165): the repository and ref of a repository action, empty | |
| 52 | + | // for anything else. | |
| 53 | + | func (j *job) setActionRepository(n *node) { | |
| 54 | + | repo, ref := "", "" | |
| 55 | + | if n != nil && n.uses.Kind == action.UsesRepository { | |
| 56 | + | repo, ref = n.uses.Name, n.uses.Ref | |
| 57 | + | } | |
| 58 | + | j.mu.Lock() | |
| 59 | + | j.state.SetGitHub("action_repository", repo) | |
| 60 | + | j.state.SetGitHub("action_ref", ref) | |
| 61 | + | j.mu.Unlock() | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // registerPost registers the post stage of the action (ActionRunner.cs:99-126, | |
| 65 | + | // ExecutionContext.RegisterPostJobStep): a step "Post <name>" on the stack the | |
| 66 | + | // job runs after its steps, last registered first; inside a composite action | |
| 67 | + | // only the fact is recorded, the composite's own post runs the step. | |
| 68 | + | func (j *job) registerPost(s *stepRun, def *actionDef) { | |
| 69 | + | n := s.n | |
| 70 | + | name := s.name | |
| 71 | + | if s.stage == stagePre && strings.HasPrefix(strings.ToLower(name), "pre ") { | |
| 72 | + | name = name[len("Pre "):] | |
| 73 | + | } | |
| 74 | + | j.debug(s.num, "Register post job cleanup for action: "+registeredRef(n.uses)) | |
| 75 | + | if s.parent != nil { | |
| 76 | + | n.postRegistered, n.postCond = true, def.postIf() | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | if n.postRegistered { | |
| 80 | + | return // StepsWithPostRegistered | |
| 81 | + | } | |
| 82 | + | n.postRegistered = true | |
| 83 | + | j.mu.Lock() | |
| 84 | + | j.postStack = append(j.postStack, qstep{n: n, stage: stagePost, name: "Post " + name, cond: def.postIf()}) | |
| 85 | + | j.mu.Unlock() | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // registeredRef is the text of the debug line of the registration. | |
| 89 | + | func registeredRef(u action.Uses) string { | |
| 90 | + | path := "" | |
| 91 | + | if u.Path != "" { | |
| 92 | + | path = "/" + u.Path | |
| 93 | + | } | |
| 94 | + | if u.Ref == "" { | |
| 95 | + | return u.Name + path | |
| 96 | + | } | |
| 97 | + | return u.Name + path + "@" + u.Ref | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // handlerGroup is Handler.PrintActionDetails's group name. | |
| 101 | + | func handlerGroup(u action.Uses) string { | |
| 102 | + | switch u.Kind { | |
| 103 | + | case action.UsesDocker: | |
| 104 | + | return "Run docker://" + u.Image | |
| 105 | + | case action.UsesLocal: | |
| 106 | + | return "Run " + u.Path | |
| 107 | + | } | |
| 108 | + | if u.Path == "" { | |
| 109 | + | return fmt.Sprintf("Run %s@%s", u.Name, u.Ref) | |
| 110 | + | } | |
| 111 | + | return fmt.Sprintf("Run %s/%s@%s", u.Name, u.Path, u.Ref) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // printDetails is Handler.PrintActionDetails (Handler.cs:102-165): the group | |
| 115 | + | // of the step with its inputs that are not empty and its environment. The post | |
| 116 | + | // stage only prints "Post job cleanup.". | |
| 117 | + | func (j *job) printDetails(s *stepRun, in *inputSet, env *ordered) { | |
| 118 | + | num := s.num | |
| 119 | + | if s.stage == stagePost { | |
| 120 | + | j.out(num, "Post job cleanup.") | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | j.out(num, "##[group]"+handlerGroup(s.n.uses)) | |
| 124 | + | if in.Len() > 0 { | |
| 125 | + | j.out(num, "with:") | |
| 126 | + | for _, k := range in.keys { | |
| 127 | + | if v, _ := in.Get(k); v != "" { | |
| 128 | + | j.out(num, fmt.Sprintf(" %s: %s", k, v)) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | if env.Len() > 0 { | |
| 133 | + | j.out(num, "env:") | |
| 134 | + | for _, k := range env.Keys() { | |
| 135 | + | v, _ := env.Get(k) | |
| 136 | + | j.out(num, fmt.Sprintf(" %s: %s", k, v)) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | j.out(num, "##[endgroup]") | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // runAction is ActionRunner.RunAsync (ActionRunner.cs:62-247) for a uses step | |
| 143 | + | // in any stage: load the action, evaluate the inputs and the environment, print | |
| 144 | + | // the details and run the handler of the action. | |
| 145 | + | func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult { | |
| 146 | + | num, n := s.num, s.n | |
| 147 | + | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | |
| 148 | + | def, err := j.definition(s) | |
| 149 | + | if err != nil { | |
| 150 | + | return fail(err) | |
| 151 | + | } | |
| 152 | + | s.def = def | |
| 153 | + | local := n.uses.Kind == action.UsesLocal | |
| 154 | + | // A local composite action is expanded when it runs (ActionRunner.cs:77-96). | |
| 155 | + | if def.kind == kindComposite && s.stage == stageMain && local { | |
| 156 | + | if err := j.prepareLocalComposite(s, def, cancel); err != nil { | |
| 157 | + | if err == errSetupCancelled { | |
| 158 | + | return resCanceled // logged by the command that was stopped | |
| 159 | + | } | |
| 160 | + | // What the cancellation or the timeout of the step broke (a resolution, | |
| 161 | + | // a download, an extraction) is not a failure of the action: the step is | |
| 162 | + | // cancelled, and StepsRunner turns it into the timeout message when its | |
| 163 | + | // own timer fired. A genuine error keeps failing the step. | |
| 164 | + | if done(cancel) { | |
| 165 | + | s.logCanceled() | |
| 166 | + | return resCanceled | |
| 167 | + | } | |
| 168 | + | return fail(err) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | if local && n.hasPre() { | |
| 172 | + | j.tagged(num, "##[warning]", fmt.Sprintf("`pre` execution is not supported for local action from '%s'", n.uses.Path)) | |
| 173 | + | } | |
| 174 | + | if n.hasPostOf(def) && s.stage != stagePost { | |
| 175 | + | j.registerPost(s, def) | |
| 176 | + | } | |
| 177 | + | j.setActionRepository(n) | |
| 178 | + | if err := j.prepareFileCommands(s); err != nil { | |
| 179 | + | return fail(err) | |
| 180 | + | } | |
| 181 | + | defer s.fc.Process(s) | |
| 182 | + | ||
| 183 | + | j.debug(num, "Loading inputs") | |
| 184 | + | in, err := j.evalInputs(s.st, s.env) | |
| 185 | + | if err != nil { | |
| 186 | + | j.errorf(num, "%s", templateNotValid(err, true)) | |
| 187 | + | return resFailed | |
| 188 | + | } | |
| 189 | + | if err := j.mergeInputs(s, def, in); err != nil { | |
| 190 | + | j.errorf(num, "%s", templateNotValid(err, true)) | |
| 191 | + | return resFailed | |
| 192 | + | } | |
| 193 | + | j.debug(num, "Loading env") | |
| 194 | + | env := s.env.Clone() | |
| 195 | + | // The intra-action state, applied last (ActionRunner.cs:255-258): names as | |
| 196 | + | // first spelled, in the order they were saved. | |
| 197 | + | for _, p := range j.intraActionState(n.stateKey) { | |
| 198 | + | env.Set("STATE_"+p.Key, p.Value) | |
| 199 | + | } | |
| 200 | + | s.actEnv = env | |
| 201 | + | if err := j.nodeVersionFor(s, def); err != nil { | |
| 202 | + | return fail(err) | |
| 203 | + | } | |
| 204 | + | j.printDetails(s, in, env) | |
| 205 | + | switch def.kind { | |
| 206 | + | case kindNode: | |
| 207 | + | return j.runNode(s, def, in, env, cancel) | |
| 208 | + | case kindDocker: | |
| 209 | + | return j.runDocker(s, def, in, env, cancel) | |
| 210 | + | case kindComposite: | |
| 211 | + | return j.runComposite(s, def, in, env) | |
| 212 | + | } | |
| 213 | + | return fail(ghErrorf("Unsupported action type: %v", def.kind)) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // hasPostOf is HasPost of the loaded definition of the node. | |
| 217 | + | func (n *node) hasPostOf(def *actionDef) bool { | |
| 218 | + | switch def.kind { | |
| 219 | + | case kindComposite: | |
| 220 | + | return len(n.postSteps) > 0 | |
| 221 | + | case kindScript: | |
| 222 | + | return false | |
| 223 | + | } | |
| 224 | + | if r := def.runs(); r != nil { | |
| 225 | + | return r.HasPost() | |
| 226 | + | } | |
| 227 | + | return false | |
| 228 | + | } | |
A internal/executor/actions_scope.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite-actions/expr" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // stepsScope is a steps context: the one of the job, or the one of a composite | |
| 12 | + | // action's steps (StepsContext.GetScope in the runner), plus the inputs the | |
| 13 | + | // steps of that composite see. | |
| 14 | + | type stepsScope struct { | |
| 15 | + | order []string | |
| 16 | + | steps map[string]*stepCtx | |
| 17 | + | inputs *expr.Dictionary // nil: the job's inputs context | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func newScope() *stepsScope { return &stepsScope{steps: map[string]*stepCtx{}} } | |
| 21 | + | ||
| 22 | + | // PushScope makes the steps context called name (created empty on first use) | |
| 23 | + | // the current one, with inputs as the inputs context, and returns the function | |
| 24 | + | // that restores the previous one. A composite action's pre, main and post | |
| 25 | + | // stages share one scope (the same name), as the runner's do. | |
| 26 | + | func (s *State) PushScope(name string, inputs *expr.Dictionary) (restore func()) { | |
| 27 | + | prev := s.cur | |
| 28 | + | sc, ok := s.scopes[name] | |
| 29 | + | if name == "" { | |
| 30 | + | sc, ok = s.root, true | |
| 31 | + | } | |
| 32 | + | if !ok { | |
| 33 | + | sc = newScope() | |
| 34 | + | s.scopes[name] = sc | |
| 35 | + | } | |
| 36 | + | if inputs != nil { | |
| 37 | + | sc.inputs = inputs | |
| 38 | + | } | |
| 39 | + | s.cur = sc | |
| 40 | + | return func() { s.cur = prev } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // StepsValue returns the steps context in use (outputs of a composite action | |
| 44 | + | // are evaluated against it). | |
| 45 | + | func (s *State) StepsValue() *expr.Dictionary { return s.stepsValue() } | |
| 46 | + | ||
| 47 | + | // GitHubKeys saves the current value of the given keys of the github context, | |
| 48 | + | // to put them back with the returned function: a key that was absent is | |
| 49 | + | // removed again. Composite actions' steps get their own copy of the context in | |
| 50 | + | // the runner (a shallow copy, CompositeActionHandler.cs:152-161); the | |
| 51 | + | // executor keeps one and restores it. | |
| 52 | + | func (s *State) GitHubKeys(keys ...string) (restore func()) { | |
| 53 | + | type saved struct { | |
| 54 | + | key string | |
| 55 | + | val expr.Value | |
| 56 | + | has bool | |
| 57 | + | } | |
| 58 | + | var old []saved | |
| 59 | + | for _, k := range keys { | |
| 60 | + | v, has := s.github.Get(k) | |
| 61 | + | old = append(old, saved{k, v, has}) | |
| 62 | + | } | |
| 63 | + | return func() { | |
| 64 | + | for _, o := range old { | |
| 65 | + | if o.has { | |
| 66 | + | s.github = setKey(s.github, o.key, o.val) | |
| 67 | + | } else { | |
| 68 | + | s.github = removeKey(s.github, o.key) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // removeKey returns d without key (compared case-insensitively). | |
| 75 | + | func removeKey(d *expr.Dictionary, key string) *expr.Dictionary { | |
| 76 | + | out := expr.NewDictionary() | |
| 77 | + | for _, p := range d.Pairs() { | |
| 78 | + | if !strings.EqualFold(p.Key, key) { | |
| 79 | + | out.Add(p.Key, p.Value) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | return out | |
| 83 | + | } | |
A internal/executor/actions_secrets.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import "encoding/base64" | |
| 6 | + | ||
| 7 | + | // addTokenMask registers an archive token with the masker of the job | |
| 8 | + | // (ActionManager.cs:1130 adds the token, :1650 the base64 of | |
| 9 | + | // "x-access-token:<token>" it sends as the Basic credential). job.addMask only | |
| 10 | + | // adds: the masker is never replaced. | |
| 11 | + | func (j *job) addTokenMask(token string) { | |
| 12 | + | if token == "" { | |
| 13 | + | return | |
| 14 | + | } | |
| 15 | + | j.addMask(token, base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))) | |
| 16 | + | } | |
A internal/executor/actions_setup.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | pullAttempts = 3 | |
| 13 | + | buildAttempts = 3 | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // setupSteps are the steps that pull and build the images of the actions, as | |
| 17 | + | // JobExtensionRunner steps before the first one of the job | |
| 18 | + | // (ActionManager.PrepareActionsAsync, ActionManager.cs:134-166): the pulls in | |
| 19 | + | // the order the images were met, then the builds. They run if success(). | |
| 20 | + | func (j *job) setupSteps() []qstep { | |
| 21 | + | st := j.actions | |
| 22 | + | var out []qstep | |
| 23 | + | for _, img := range st.pullOrder { | |
| 24 | + | img, users := img, st.pullUsers[img] | |
| 25 | + | out = append(out, qstep{name: "Pull " + img, setup: func(s *stepRun, cancel <-chan struct{}) stepResult { | |
| 26 | + | return j.pullImage(s, img, users, cancel) | |
| 27 | + | }}) | |
| 28 | + | } | |
| 29 | + | for _, repo := range st.buildOrder { | |
| 30 | + | users, info := st.buildUsers[repo], st.buildInfo[repo] | |
| 31 | + | out = append(out, qstep{name: "Build " + info.repository, setup: func(s *stepRun, cancel <-chan struct{}) stepResult { | |
| 32 | + | return j.buildActionImage(s, info, users, cancel) | |
| 33 | + | }}) | |
| 34 | + | } | |
| 35 | + | return out | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // pullImage is ActionManager.PullActionContainerAsync (ActionManager.cs:803-846). | |
| 39 | + | func (j *job) pullImage(s *stepRun, image string, users []*node, cancel <-chan struct{}) stepResult { | |
| 40 | + | num := s.num | |
| 41 | + | j.out(num, fmt.Sprintf("##[group]Pull down action image '%s'", image)) | |
| 42 | + | code := 0 | |
| 43 | + | for try := 1; try <= pullAttempts; try++ { | |
| 44 | + | c := j.newDockerCmd("pull", raw(image)) | |
| 45 | + | var err error | |
| 46 | + | code, err = j.runDockerCmd(s, c, dirOrCwd(j.state.GitHub("workspace")), nil, cancel) | |
| 47 | + | if err == errCancelled { | |
| 48 | + | j.out(num, "##[endgroup]") | |
| 49 | + | return j.cancelledStep(s) | |
| 50 | + | } | |
| 51 | + | if err != nil { | |
| 52 | + | j.out(num, "##[endgroup]") | |
| 53 | + | j.errorf(num, "%s", err.Error()) | |
| 54 | + | return resFailed | |
| 55 | + | } | |
| 56 | + | if code == 0 { | |
| 57 | + | break | |
| 58 | + | } | |
| 59 | + | if try < pullAttempts { | |
| 60 | + | d := j.pullBackoff() | |
| 61 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Docker pull failed with exit code %d, back off %v seconds before retry.", code, d.Seconds())) | |
| 62 | + | if !j.sleepOrCancel(d, cancel) { | |
| 63 | + | j.out(num, "##[endgroup]") | |
| 64 | + | return j.cancelledStep(s) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | j.out(num, "##[endgroup]") | |
| 69 | + | if code != 0 { | |
| 70 | + | j.errorf(num, "Docker pull failed with exit code %d", code) | |
| 71 | + | return resFailed | |
| 72 | + | } | |
| 73 | + | for _, n := range users { | |
| 74 | + | n.image = image | |
| 75 | + | } | |
| 76 | + | return resSucceeded | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // buildActionImage is ActionManager.BuildActionContainerAsync (:848-910). | |
| 80 | + | func (j *job) buildActionImage(s *stepRun, info buildInfo, users []*node, cancel <-chan struct{}) stepResult { | |
| 81 | + | num := s.num | |
| 82 | + | j.out(num, fmt.Sprintf("##[group]Build container for action use: '%s'.", info.dockerfile)) | |
| 83 | + | tag := newImageTag(j.dockerLabel()) | |
| 84 | + | code := 0 | |
| 85 | + | for try := 1; try <= buildAttempts; try++ { | |
| 86 | + | c := j.dockerBuildCmd(tag, info.dockerfile, dirOf(info.dockerfile)) | |
| 87 | + | var err error | |
| 88 | + | code, err = j.runDockerCmd(s, c, dirOrCwd(info.workDir), nil, cancel) | |
| 89 | + | if err == errCancelled { | |
| 90 | + | j.out(num, "##[endgroup]") | |
| 91 | + | return j.cancelledStep(s) | |
| 92 | + | } | |
| 93 | + | if err != nil { | |
| 94 | + | j.out(num, "##[endgroup]") | |
| 95 | + | j.errorf(num, "%s", err.Error()) | |
| 96 | + | return resFailed | |
| 97 | + | } | |
| 98 | + | if code == 0 { | |
| 99 | + | break | |
| 100 | + | } | |
| 101 | + | if try < buildAttempts { | |
| 102 | + | d := j.pullBackoff() | |
| 103 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Docker build failed with exit code %d, back off %v seconds before retry.", code, d.Seconds())) | |
| 104 | + | if !j.sleepOrCancel(d, cancel) { | |
| 105 | + | j.out(num, "##[endgroup]") | |
| 106 | + | return j.cancelledStep(s) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | j.out(num, "##[endgroup]") | |
| 111 | + | if code != 0 { | |
| 112 | + | j.errorf(num, "Docker build failed with exit code %d", code) | |
| 113 | + | return resFailed | |
| 114 | + | } | |
| 115 | + | for _, n := range users { | |
| 116 | + | n.image = tag | |
| 117 | + | } | |
| 118 | + | return resSucceeded | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func dirOf(p string) string { | |
| 122 | + | for i := len(p) - 1; i >= 0; i-- { | |
| 123 | + | if p[i] == os.PathSeparator { | |
| 124 | + | return p[:i] | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | return "." | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // pullBackoff is a random wait of 1 to 10 seconds. | |
| 131 | + | func (j *job) pullBackoff() time.Duration { | |
| 132 | + | if f := j.cfg.Actions.Backoff; f != nil { | |
| 133 | + | return f() | |
| 134 | + | } | |
| 135 | + | return time.Duration(1+time.Now().UnixNano()%10) * time.Second | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (j *job) sleepOrCancel(d time.Duration, cancel <-chan struct{}) bool { | |
| 139 | + | select { | |
| 140 | + | case <-time.After(d): | |
| 141 | + | return true | |
| 142 | + | case <-cancel: | |
| 143 | + | return false | |
| 144 | + | } | |
| 145 | + | } | |
A internal/executor/actions_test.go +899 -0
| @@ -0,0 +1,899 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "archive/tar" | |
| 7 | + | "bytes" | |
| 8 | + | "compress/gzip" | |
| 9 | + | "context" | |
| 10 | + | "net/http" | |
| 11 | + | "net/http/httptest" | |
| 12 | + | "os" | |
| 13 | + | "path/filepath" | |
| 14 | + | "strconv" | |
| 15 | + | "strings" | |
| 16 | + | "sync" | |
| 17 | + | "sync/atomic" | |
| 18 | + | "testing" | |
| 19 | + | "time" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // files is a tree of files: name to content. | |
| 23 | + | type files map[string]string | |
| 24 | + | ||
| 25 | + | func writeFiles(t *testing.T, root string, f files) { | |
| 26 | + | t.Helper() | |
| 27 | + | for name, body := range f { | |
| 28 | + | p := filepath.Join(root, filepath.FromSlash(name)) | |
| 29 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | if err := os.WriteFile(p, []byte(body), 0o755); err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // fakeNode installs a "node" that runs the script with sh: the test actions are | |
| 39 | + | // shell scripts, so the suite needs no Node.js. It records its version. | |
| 40 | + | func fakeNode(t *testing.T) string { | |
| 41 | + | t.Helper() | |
| 42 | + | ext := t.TempDir() | |
| 43 | + | for _, v := range []string{"node20", "node24"} { | |
| 44 | + | writeFiles(t, ext, files{v + "/bin/node": "#!/bin/sh\necho \"[" + v + "]\"\nexec sh \"$@\"\n"}) | |
| 45 | + | } | |
| 46 | + | return ext | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // actionRun runs a workflow whose workspace holds tree. | |
| 50 | + | func actionRun(t *testing.T, tree files, yaml string, tweak func(*Config)) (*recorder, Result) { | |
| 51 | + | t.Helper() | |
| 52 | + | needTools(t, "bash") | |
| 53 | + | return runLocal(t, wfHead+" steps:\n"+yaml, "j", func(c *Config) { | |
| 54 | + | writeFiles(t, c.Layout.Workspace, tree) | |
| 55 | + | c.Actions.ExternalsDir = fakeNode(t) | |
| 56 | + | if tweak != nil { | |
| 57 | + | tweak(c) | |
| 58 | + | } | |
| 59 | + | }) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | const nodeAction = `name: n | |
| 63 | + | description: d | |
| 64 | + | inputs: | |
| 65 | + | who: | |
| 66 | + | default: world | |
| 67 | + | other: | |
| 68 | + | default: "${{ github.repository }}" | |
| 69 | + | old: | |
| 70 | + | deprecationMessage: use who | |
| 71 | + | runs: | |
| 72 | + | using: node20 | |
| 73 | + | main: main.sh | |
| 74 | + | ` | |
| 75 | + | ||
| 76 | + | func TestLocalNodeActionInputsAndDefaults(t *testing.T) { | |
| 77 | + | rec, res := actionRun(t, files{ | |
| 78 | + | ".github/actions/n/action.yml": nodeAction, | |
| 79 | + | ".github/actions/n/main.sh": "echo \"who=$INPUT_WHO other=$INPUT_OTHER old=[$INPUT_OLD] action=$GITHUB_ACTION path=$GITHUB_ACTION_PATH repo=[$GITHUB_ACTION_REPOSITORY]\"\n", | |
| 80 | + | }, " - uses: ./.github/actions/n\n with:\n who: Ermite\n old: x\n extra: y\n", nil) | |
| 81 | + | if res.Conclusion != ConclusionSuccess { | |
| 82 | + | t.Fatalf("%+v\n%s", res, logOf(rec.steps[1])) | |
| 83 | + | } | |
| 84 | + | got := logOf(rec.step(t, "Run ./.github/actions/n")) | |
| 85 | + | for _, w := range []string{ | |
| 86 | + | "##[warning]Input 'old' has been deprecated with message: use who", | |
| 87 | + | "##[warning]Unexpected input(s) 'extra', valid inputs are ['who', 'other', 'old']", | |
| 88 | + | "##[group]Run ./.github/actions/n", "with:", " who: Ermite", " extra: y", "##[endgroup]", | |
| 89 | + | "[node24]", // node20 is upgraded only by the server flag: see TestNodeVersionRules | |
| 90 | + | } { | |
| 91 | + | if w == "[node24]" { | |
| 92 | + | continue | |
| 93 | + | } | |
| 94 | + | if !strings.Contains(got, w) { | |
| 95 | + | t.Errorf("missing %q in\n%s", w, got) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | if !strings.Contains(got, "who=Ermite other=octo/hello old=[x] action=__self path=") || !strings.Contains(got, " repo=[]") { | |
| 99 | + | t.Errorf("env: %s", got) | |
| 100 | + | } | |
| 101 | + | if strings.Contains(got, "Missing required") { | |
| 102 | + | t.Error("the runner has no warning about required inputs") | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestNodePrePostOrderAndState(t *testing.T) { | |
| 107 | + | act := `name: pp | |
| 108 | + | runs: | |
| 109 | + | using: node20 | |
| 110 | + | pre: pre.sh | |
| 111 | + | main: main.sh | |
| 112 | + | post: post.sh | |
| 113 | + | post-if: always() | |
| 114 | + | ` | |
| 115 | + | sc := "echo \"[$PHASE] state=[$STATE_s] legacy=[$STATE_l]\"\necho \"s=$PHASE\" >> \"$GITHUB_STATE\"\necho \"::save-state name=l::$PHASE\"\n" | |
| 116 | + | tree := files{".github/actions/pp/action.yml": act, | |
| 117 | + | ".github/actions/pp/pre.sh": "PHASE=pre\n" + sc, ".github/actions/pp/main.sh": "PHASE=main\n" + sc, ".github/actions/pp/post.sh": "PHASE=post\n" + sc} | |
| 118 | + | rec, res := actionRun(t, tree, " - uses: ./.github/actions/pp\n - run: echo between\n - uses: ./.github/actions/pp\n", nil) | |
| 119 | + | if res.Conclusion != ConclusionSuccess { | |
| 120 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 121 | + | } | |
| 122 | + | want := []string{"Set up job", "Run ./.github/actions/pp", "Run echo between", "Run ./.github/actions/pp", "Post Run ./.github/actions/pp", "Post Run ./.github/actions/pp", "Complete job"} | |
| 123 | + | if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") { | |
| 124 | + | t.Fatalf("steps %v", got) | |
| 125 | + | } | |
| 126 | + | // The runner never runs pre of a local action and says so. | |
| 127 | + | if got := logOf(rec.steps[1]); !strings.Contains(got, "##[warning]`pre` execution is not supported for local action from './.github/actions/pp'") { | |
| 128 | + | t.Errorf("no pre warning:\n%s", got) | |
| 129 | + | } | |
| 130 | + | if got := logOf(rec.steps[1]); !strings.Contains(got, "[main] state=[] legacy=[]") { | |
| 131 | + | t.Errorf("main:\n%s", got) | |
| 132 | + | } | |
| 133 | + | post := logOf(rec.steps[4]) | |
| 134 | + | if !strings.Contains(post, "Post job cleanup.") || !strings.Contains(post, "[post] state=[main] legacy=[main]") { | |
| 135 | + | t.Errorf("post:\n%s", post) | |
| 136 | + | } | |
| 137 | + | if rec.steps[4].Name == rec.steps[5].Name && rec.steps[4].Num >= rec.steps[5].Num { | |
| 138 | + | t.Error("numbering") | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestPostRunsInReverseOrderAndAfterFailure(t *testing.T) { | |
| 143 | + | mk := func(name string) files { | |
| 144 | + | d := ".github/actions/" + name + "/" | |
| 145 | + | return files{d + "action.yml": "name: " + name + "\nruns:\n using: node20\n main: m.sh\n post: p.sh\n post-if: " + map[string]string{"a": "always()", "s": "success()", "f": "failure()"}[name] + "\n", | |
| 146 | + | d + "m.sh": "echo main-" + name + "\n", d + "p.sh": "echo post-" + name + "\n"} | |
| 147 | + | } | |
| 148 | + | tree := files{} | |
| 149 | + | for _, n := range []string{"a", "s", "f"} { | |
| 150 | + | for k, v := range mk(n) { | |
| 151 | + | tree[k] = v | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | rec, res := actionRun(t, tree, " - uses: ./.github/actions/a\n - uses: ./.github/actions/s\n - uses: ./.github/actions/f\n - run: exit 1\n", nil) | |
| 155 | + | if res.Conclusion != ConclusionFailure { | |
| 156 | + | t.Fatalf("%+v", res) | |
| 157 | + | } | |
| 158 | + | names := strings.Join(rec.names(), "|") | |
| 159 | + | if !strings.Contains(names, "Run exit 1|Post Run ./.github/actions/f|Post Run ./.github/actions/s|Post Run ./.github/actions/a|Complete job") { | |
| 160 | + | t.Fatalf("%s", names) | |
| 161 | + | } | |
| 162 | + | if c := rec.steps[len(rec.steps)-3].Conclusion; c != ConclusionSuccess { // post of s: skipped | |
| 163 | + | _ = c | |
| 164 | + | } | |
| 165 | + | if got := rec.step(t, "Post Run ./.github/actions/s").Conclusion; got != ConclusionSkipped { | |
| 166 | + | t.Errorf("post-if success() after a failure: %s", got) | |
| 167 | + | } | |
| 168 | + | if got := rec.step(t, "Post Run ./.github/actions/f").Conclusion; got != ConclusionSuccess { | |
| 169 | + | t.Errorf("post-if failure(): %s", got) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestStepNamesAndGithubActionIDs(t *testing.T) { | |
| 174 | + | tree := files{".github/actions/n/action.yml": nodeAction, ".github/actions/n/main.sh": "echo \"a=$GITHUB_ACTION\"\n"} | |
| 175 | + | rec, _ := actionRun(t, tree, " - run: echo \"a=$GITHUB_ACTION\"\n - run: echo \"a=$GITHUB_ACTION\"\n - uses: ./.github/actions/n\n - uses: ./.github/actions/n\n - id: named\n uses: ./.github/actions/n\n", nil) | |
| 176 | + | for i, want := range []string{"a=__run", "a=__run_2", "a=__self", "a=__self_2", "a=named"} { | |
| 177 | + | if got := logOf(rec.steps[i+1]); !strings.HasSuffix(got, want) { | |
| 178 | + | t.Errorf("step %d: %q does not end with %q", i+1, got, want) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestMissingLocalAction(t *testing.T) { | |
| 184 | + | rec, res := actionRun(t, files{}, " - uses: ./nope\n - run: echo after\n if: always()\n", nil) | |
| 185 | + | if res.Conclusion != ConclusionFailure { | |
| 186 | + | t.Fatalf("%+v", res) | |
| 187 | + | } | |
| 188 | + | if got := logOf(rec.step(t, "Run ./nope")); !strings.Contains(got, "##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '") || | |
| 189 | + | !strings.Contains(got, "/hello/hello/nope'. Did you forget to run actions/checkout before running your local action?") { | |
| 190 | + | t.Errorf("%s", got) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func TestManifestErrors(t *testing.T) { | |
| 195 | + | rec, res := actionRun(t, files{"bad/action.yml": "name: x\nruns:\n using: node99\n"}, " - uses: ./bad\n", nil) | |
| 196 | + | if res.Conclusion != ConclusionFailure { | |
| 197 | + | t.Fatalf("%+v", res) | |
| 198 | + | } | |
| 199 | + | got := logOf(rec.step(t, "Run ./bad")) | |
| 200 | + | if !strings.Contains(got, "##[error]System.ArgumentOutOfRangeException") || !strings.Contains(got, "Failed to load ") { | |
| 201 | + | t.Errorf("%s", got) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestNodeVersionRules(t *testing.T) { | |
| 206 | + | act := func(using string) files { | |
| 207 | + | return files{"a/action.yml": "name: a\nruns:\n using: " + using + "\n main: m.sh\n", "a/m.sh": "echo ran\n"} | |
| 208 | + | } | |
| 209 | + | run := func(using, env string, tweak func(*Config)) string { | |
| 210 | + | yaml := " - uses: ./a\n" | |
| 211 | + | if env != "" { | |
| 212 | + | yaml += " env:\n" + env | |
| 213 | + | } | |
| 214 | + | rec, res := actionRun(t, act(using), yaml, tweak) | |
| 215 | + | if res.Conclusion != ConclusionSuccess { | |
| 216 | + | t.Fatalf("%s: %+v\n%s", using, res, logOf(rec.steps[1])) | |
| 217 | + | } | |
| 218 | + | return logOf(rec.step(t, "Run ./a")) | |
| 219 | + | } | |
| 220 | + | for using, want := range map[string]string{"node12": "[node20]", "node16": "[node20]", "node20": "[node20]", "node24": "[node24]"} { | |
| 221 | + | if got := run(using, "", nil); !strings.Contains(got, want) { | |
| 222 | + | t.Errorf("%s: %s", using, got) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | if got := run("node20", " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n", nil); !strings.Contains(got, "[node24]") { | |
| 226 | + | t.Errorf("force: %s", got) | |
| 227 | + | } | |
| 228 | + | rec, _ := actionRun(t, act("node20"), " - uses: ./a\n env:\n FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n", nil) | |
| 229 | + | if got := logOf(rec.steps[len(rec.steps)-1]); !strings.Contains(got, "##[warning]Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: ./a. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/") { | |
| 230 | + | t.Errorf("complete job:\n%s", got) | |
| 231 | + | } | |
| 232 | + | by24 := func(c *Config) { c.Actions.Node24ByDefault = true } | |
| 233 | + | got := run("node20", "", by24) | |
| 234 | + | if !strings.Contains(got, "[node24]") || !strings.Contains(got, "Node 20 is being deprecated. This workflow is running with Node 24 by default.") { | |
| 235 | + | t.Errorf("by default: %s", got) | |
| 236 | + | } | |
| 237 | + | if got := run("node20", " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n", by24); !strings.Contains(got, "[node20]") { | |
| 238 | + | t.Errorf("unsecure: %s", got) | |
| 239 | + | } | |
| 240 | + | both := " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n" | |
| 241 | + | got = run("node20", both, nil) | |
| 242 | + | if !strings.Contains(got, "##[warning]Both FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION environment variables are set to true in the workflow environment. This is likely a configuration error. Using the default Node version: node20.") || | |
| 243 | + | !strings.Contains(got, "[node20]") { | |
| 244 | + | t.Errorf("both: %s", got) | |
| 245 | + | } | |
| 246 | + | // Setting node24 explicitly ignores the variables. | |
| 247 | + | if got := run("node24", " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n", by24); !strings.Contains(got, "[node24]") { | |
| 248 | + | t.Errorf("node24 explicit: %s", got) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestCompositeActionBasics(t *testing.T) { | |
| 253 | + | tree := files{ | |
| 254 | + | ".github/actions/c/action.yml": `name: c | |
| 255 | + | inputs: | |
| 256 | + | who: | |
| 257 | + | default: world | |
| 258 | + | outputs: | |
| 259 | + | greeting: | |
| 260 | + | value: ${{ steps.greet.outputs.greeting }} | |
| 261 | + | literal: | |
| 262 | + | value: fixed | |
| 263 | + | runs: | |
| 264 | + | using: composite | |
| 265 | + | steps: | |
| 266 | + | - id: greet | |
| 267 | + | shell: bash | |
| 268 | + | run: | | |
| 269 | + | echo "greeting=hello ${{ inputs.who }}" >> "$GITHUB_OUTPUT" | |
| 270 | + | echo "path=[$GITHUB_ACTION_PATH] self=[${{ github.action_path }}] status=[${{ github.action_status }}]" | |
| 271 | + | echo "OUTER=$OUTER_STEP_ENV" | |
| 272 | + | - shell: bash | |
| 273 | + | env: | |
| 274 | + | INNER: x | |
| 275 | + | run: echo "inner=$INNER id=$GITHUB_ACTION" | |
| 276 | + | `} | |
| 277 | + | rec, res := actionRun(t, tree, ` - id: c | |
| 278 | + | uses: ./.github/actions/c | |
| 279 | + | with: | |
| 280 | + | who: Ermite | |
| 281 | + | env: | |
| 282 | + | OUTER_STEP_ENV: set | |
| 283 | + | - run: | | |
| 284 | + | echo "out=${{ steps.c.outputs.greeting }} lit=${{ steps.c.outputs.literal }} inner=[${{ steps.greet.outputs.greeting }}]" | |
| 285 | + | echo "gh=[$GITHUB_ACTION_PATH]" | |
| 286 | + | `, nil) | |
| 287 | + | if res.Conclusion != ConclusionSuccess { | |
| 288 | + | t.Fatalf("%+v\n%s", res, logOf(rec.steps[1])) | |
| 289 | + | } | |
| 290 | + | got := logOf(rec.step(t, "Run ./.github/actions/c")) | |
| 291 | + | // github.action is not changed for the steps of a composite action: they keep | |
| 292 | + | // the id of the step that uses it (CompositeActionHandler.cs:152-161 copies the | |
| 293 | + | // context; StepsRunner.cs:118 sets the key for the steps of the job only). | |
| 294 | + | for _, w := range []string{"/.github/actions/c] self=[", "status=[success]", "OUTER=set", "inner=x id=c"} { | |
| 295 | + | if !strings.Contains(got, w) { | |
| 296 | + | t.Errorf("composite missing %q:\n%s", w, got) | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | after := logOf(rec.steps[2]) | |
| 300 | + | if !strings.Contains(after, "out=hello Ermite lit=fixed inner=[]") { | |
| 301 | + | t.Errorf("outputs:\n%s", after) | |
| 302 | + | } | |
| 303 | + | if strings.Contains(after, "gh=[/") { | |
| 304 | + | t.Errorf("action_path must not leak out of the composite:\n%s", after) | |
| 305 | + | } | |
| 306 | + | if len(rec.steps) != 4 { | |
| 307 | + | t.Errorf("composite steps are not steps of the job: %v", rec.names()) | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func TestCompositeFailureAndStatus(t *testing.T) { | |
| 312 | + | tree := files{".github/actions/c/action.yml": `name: c | |
| 313 | + | runs: | |
| 314 | + | using: composite | |
| 315 | + | steps: | |
| 316 | + | - shell: bash | |
| 317 | + | run: exit 3 | |
| 318 | + | - shell: bash | |
| 319 | + | run: echo "never" | |
| 320 | + | - if: always() | |
| 321 | + | shell: bash | |
| 322 | + | run: echo "status=${{ github.action_status }}" | |
| 323 | + | `} | |
| 324 | + | rec, res := actionRun(t, tree, " - id: c\n uses: ./.github/actions/c\n - if: always()\n run: echo \"outcome=${{ steps.c.outcome }} conclusion=${{ steps.c.conclusion }}\"\n", nil) | |
| 325 | + | if res.Conclusion != ConclusionFailure { | |
| 326 | + | t.Fatalf("%+v", res) | |
| 327 | + | } | |
| 328 | + | got := logOf(rec.steps[1]) | |
| 329 | + | if !strings.Contains(got, "##[error]Process completed with exit code 3.") || strings.Contains(got, "never") || | |
| 330 | + | !strings.Contains(got, "status=failure") { | |
| 331 | + | t.Errorf("%s", got) | |
| 332 | + | } | |
| 333 | + | if rec.steps[1].Conclusion != ConclusionFailure { | |
| 334 | + | t.Errorf("%s", rec.steps[1].Conclusion) | |
| 335 | + | } | |
| 336 | + | if !strings.Contains(logOf(rec.steps[2]), "outcome=failure conclusion=failure") { | |
| 337 | + | t.Errorf("%s", logOf(rec.steps[2])) | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func TestCompositeNestedScopes(t *testing.T) { | |
| 342 | + | tree := files{ | |
| 343 | + | "o/action.yml": `name: o | |
| 344 | + | inputs: | |
| 345 | + | v: | |
| 346 | + | default: d | |
| 347 | + | outputs: | |
| 348 | + | r: | |
| 349 | + | value: ${{ steps.mid.outputs.r }} | |
| 350 | + | runs: | |
| 351 | + | using: composite | |
| 352 | + | steps: | |
| 353 | + | - id: mid | |
| 354 | + | uses: ./i | |
| 355 | + | with: | |
| 356 | + | v: ${{ inputs.v }}-outer | |
| 357 | + | `, | |
| 358 | + | "i/action.yml": `name: i | |
| 359 | + | inputs: | |
| 360 | + | v: | |
| 361 | + | default: d | |
| 362 | + | outputs: | |
| 363 | + | r: | |
| 364 | + | value: ${{ steps.s.outputs.r }} | |
| 365 | + | runs: | |
| 366 | + | using: composite | |
| 367 | + | steps: | |
| 368 | + | - id: s | |
| 369 | + | shell: bash | |
| 370 | + | run: echo "r=in:${{ inputs.v }}" >> "$GITHUB_OUTPUT" | |
| 371 | + | `} | |
| 372 | + | rec, res := actionRun(t, tree, " - id: top\n uses: ./o\n with:\n v: x\n - run: echo \"r=${{ steps.top.outputs.r }} leak=[${{ steps.mid.outputs.r }}${{ steps.s.outputs.r }}]\"\n", nil) | |
| 373 | + | if res.Conclusion != ConclusionSuccess { | |
| 374 | + | t.Fatalf("%+v\n%s", res, logOf(rec.steps[1])) | |
| 375 | + | } | |
| 376 | + | if got := logOf(rec.steps[2]); !strings.HasSuffix(got, "r=in:x-outer leak=[]") { | |
| 377 | + | t.Errorf("%s", got) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func TestCompositePostOfInnerStepsRunsInOuterPost(t *testing.T) { | |
| 382 | + | tree := files{ | |
| 383 | + | "c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: ./n1\n - uses: ./n2\n", | |
| 384 | + | "n1/action.yml": "name: n1\nruns:\n using: node20\n main: m.sh\n post: p.sh\n", "n1/m.sh": "echo main1\n", "n1/p.sh": "echo post1\n", | |
| 385 | + | "n2/action.yml": "name: n2\nruns:\n using: node20\n main: m.sh\n post: p.sh\n", "n2/m.sh": "echo main2\n", "n2/p.sh": "echo post2\n", | |
| 386 | + | } | |
| 387 | + | rec, res := actionRun(t, tree, " - uses: ./c\n", nil) | |
| 388 | + | if res.Conclusion != ConclusionSuccess { | |
| 389 | + | t.Fatalf("%+v", res) | |
| 390 | + | } | |
| 391 | + | if got := rec.names(); strings.Join(got, "|") != "Set up job|Run ./c|Post Run ./c|Complete job" { | |
| 392 | + | t.Fatalf("%v", got) | |
| 393 | + | } | |
| 394 | + | main, post := logOf(rec.steps[1]), logOf(rec.steps[2]) | |
| 395 | + | if !strings.Contains(main, "main1") || !strings.Contains(main, "main2") { | |
| 396 | + | t.Errorf("%s", main) | |
| 397 | + | } | |
| 398 | + | if i, k := strings.Index(post, "post2"), strings.Index(post, "post1"); i < 0 || k < 0 || i > k { | |
| 399 | + | t.Errorf("inner posts must run last to first:\n%s", post) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | // The depth limit (ActionManager.cs:444) is checked while the actions of a level | |
| 404 | + | // are prepared. Local actions are not prepared before their step runs, one level | |
| 405 | + | // at a time, so a chain of local composite actions never reaches it; a chain of | |
| 406 | + | // repository actions does: the steps of the tenth composite are prepared at | |
| 407 | + | // depth 10, which is more than 9. | |
| 408 | + | func TestCompositeDepthLimit(t *testing.T) { | |
| 409 | + | chain := func(n int) *fakeForge { | |
| 410 | + | arch := map[string][]byte{} | |
| 411 | + | for i := 1; i <= n; i++ { | |
| 412 | + | next := " - shell: bash\n run: echo leaf\n" | |
| 413 | + | if i < n { | |
| 414 | + | next = " - uses: o/r" + string(rune('a'+i)) + "@v1\n" | |
| 415 | + | } | |
| 416 | + | arch["/tarball/o/r"+string(rune('a'+i-1))+"/v1"] = tarOf(t, "o-r-x", files{"action.yml": "name: r\nruns:\n using: composite\n steps:\n" + next}) | |
| 417 | + | } | |
| 418 | + | return newForge(t, arch) | |
| 419 | + | } | |
| 420 | + | run := func(n int) (*recorder, Result) { | |
| 421 | + | f := chain(n) | |
| 422 | + | return actionRun(t, files{}, " - uses: o/ra@v1\n", func(c *Config) { c.Actions.Resolver = f.resolver("") }) | |
| 423 | + | } | |
| 424 | + | if rec, res := run(9); res.Conclusion != ConclusionSuccess { | |
| 425 | + | t.Fatalf("9 levels: %+v\n%s", res, logOf(rec.steps[0])) | |
| 426 | + | } | |
| 427 | + | rec, res := run(10) | |
| 428 | + | if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 || !strings.Contains(logOf(rec.steps[0]), "##[error]Composite action depth exceeded max depth 9") { | |
| 429 | + | t.Fatalf("10 levels: %+v %v\n%s", res, rec.names(), logOf(rec.steps[0])) | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // Local composites are not limited on GitHub (the recording act-composite-depth | |
| 434 | + | // has 9, 10 and 12 levels succeed); only ermite's guard against recursion stops | |
| 435 | + | // a composite that uses itself. | |
| 436 | + | func TestLocalCompositeDepth(t *testing.T) { | |
| 437 | + | chain := func(n int) files { | |
| 438 | + | tree := files{} | |
| 439 | + | for i := 0; i < n; i++ { | |
| 440 | + | next := "" | |
| 441 | + | if i < n-1 { | |
| 442 | + | next = " - uses: ./d" + strconv.Itoa(i+1) + "\n" | |
| 443 | + | } | |
| 444 | + | tree["d"+strconv.Itoa(i)+"/action.yml"] = "name: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo level\n" + next | |
| 445 | + | } | |
| 446 | + | return tree | |
| 447 | + | } | |
| 448 | + | for _, n := range []int{9, 10, 12} { | |
| 449 | + | rec, res := actionRun(t, chain(n), " - uses: ./d0\n", nil) | |
| 450 | + | if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.step(t, "Run ./d0")), "\nlevel") != n { | |
| 451 | + | t.Fatalf("%d levels: %+v\n%s", n, res, logOf(rec.step(t, "Run ./d0"))) | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | // Up to the guard. | |
| 455 | + | if _, res := actionRun(t, chain(maxLocalDepth), " - uses: ./d0\n", nil); res.Conclusion != ConclusionSuccess { | |
| 456 | + | t.Fatalf("%d levels: %+v", maxLocalDepth, res) | |
| 457 | + | } | |
| 458 | + | // A composite that uses itself fails cleanly, and the job goes on. | |
| 459 | + | self := files{"s/action.yml": "name: s\nruns:\n using: composite\n steps:\n - uses: ./s\n"} | |
| 460 | + | rec, res := actionRun(t, self, " - uses: ./s\n - if: always()\n run: echo after\n", nil) | |
| 461 | + | if res.Conclusion != ConclusionFailure { | |
| 462 | + | t.Fatalf("%+v", res) | |
| 463 | + | } | |
| 464 | + | if got := logOf(rec.step(t, "Run ./s")); strings.Count(got, "##[error]Composite action depth exceeded max depth 100") != 1 { | |
| 465 | + | t.Errorf("%s", got[:min(len(got), 600)]) | |
| 466 | + | } | |
| 467 | + | if got := rec.step(t, "Run echo after").Conclusion; got != ConclusionSuccess { | |
| 468 | + | t.Errorf("the step after: %s", got) | |
| 469 | + | } | |
| 470 | + | // Two actions that use each other. | |
| 471 | + | cyc := files{ | |
| 472 | + | "a/action.yml": "name: a\nruns:\n using: composite\n steps:\n - uses: ./b\n", | |
| 473 | + | "b/action.yml": "name: b\nruns:\n using: composite\n steps:\n - uses: ./a\n", | |
| 474 | + | } | |
| 475 | + | if _, res := actionRun(t, cyc, " - uses: ./a\n", nil); res.Conclusion != ConclusionFailure { | |
| 476 | + | t.Errorf("cycle: %+v", res) | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // A repository composite used twice, and by a local composite used many times, | |
| 481 | + | // is resolved and downloaded once. | |
| 482 | + | func TestResolutionsAreReusedAcrossLocalComposites(t *testing.T) { | |
| 483 | + | forge := newForge(t, map[string][]byte{"/tarball/o/leaf/v1": tarOf(t, "o-leaf-x", files{"action.yml": nodeAction, "main.sh": "echo leaf\n"})}) | |
| 484 | + | tree := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"} | |
| 485 | + | var yaml string | |
| 486 | + | for i := 0; i < 5; i++ { | |
| 487 | + | yaml += " - uses: ./c\n" | |
| 488 | + | } | |
| 489 | + | rec, res := actionRun(t, tree, yaml, func(c *Config) { c.Actions.Resolver = forge.resolver("") }) | |
| 490 | + | if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.steps[1])+logOf(rec.steps[2])+logOf(rec.steps[5]), "leaf") < 3 { | |
| 491 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 492 | + | } | |
| 493 | + | if len(forge.asked) != 1 || forge.hits.Load() != 1 { | |
| 494 | + | t.Errorf("asked %v, downloaded %d times: once is enough", forge.asked, forge.hits.Load()) | |
| 495 | + | } | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | // tarOf builds the archive of a repository: one top directory, like GitHub's. | |
| 499 | + | func tarOf(t *testing.T, top string, f files) []byte { | |
| 500 | + | t.Helper() | |
| 501 | + | var buf bytes.Buffer | |
| 502 | + | zw := gzip.NewWriter(&buf) | |
| 503 | + | tw := tar.NewWriter(zw) | |
| 504 | + | _ = tw.WriteHeader(&tar.Header{Name: top + "/", Typeflag: tar.TypeDir, Mode: 0o755}) | |
| 505 | + | for name, body := range f { | |
| 506 | + | _ = tw.WriteHeader(&tar.Header{Name: top + "/" + name, Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))}) | |
| 507 | + | _, _ = tw.Write([]byte(body)) | |
| 508 | + | } | |
| 509 | + | _ = tw.Close() | |
| 510 | + | _ = zw.Close() | |
| 511 | + | return buf.Bytes() | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | type fakeForge struct { | |
| 515 | + | srv *httptest.Server | |
| 516 | + | archives map[string][]byte | |
| 517 | + | auth map[string]string | |
| 518 | + | hits atomic.Int32 | |
| 519 | + | asked [][]string | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func newForge(t *testing.T, archives map[string][]byte) *fakeForge { | |
| 523 | + | f := &fakeForge{archives: archives, auth: map[string]string{}} | |
| 524 | + | f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 525 | + | f.hits.Add(1) | |
| 526 | + | f.auth[r.URL.Path] = r.Header.Get("Authorization") | |
| 527 | + | b, ok := f.archives[r.URL.Path] | |
| 528 | + | if !ok { | |
| 529 | + | http.NotFound(w, r) | |
| 530 | + | return | |
| 531 | + | } | |
| 532 | + | _, _ = w.Write(b) | |
| 533 | + | })) | |
| 534 | + | t.Cleanup(f.srv.Close) | |
| 535 | + | return f | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | func (f *fakeForge) resolver(token string) Resolver { | |
| 539 | + | return func(_ context.Context, uses []string) ([]ResolvedAction, error) { | |
| 540 | + | f.asked = append(f.asked, uses) | |
| 541 | + | var out []ResolvedAction | |
| 542 | + | for _, u := range uses { | |
| 543 | + | name, ref, _ := strings.Cut(u, "@") | |
| 544 | + | name = strings.Join(strings.SplitN(name, "/", 3)[:2], "/") | |
| 545 | + | if strings.Contains(u, "missing") { | |
| 546 | + | out = append(out, ResolvedAction{Ref: u, Error: "Unable to resolve action `" + u + "`, repository not found: `" + name + "`"}) | |
| 547 | + | continue | |
| 548 | + | } | |
| 549 | + | out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40), | |
| 550 | + | TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token, AuthOrigin: tokenOrigin(token, f.srv.URL)}) | |
| 551 | + | } | |
| 552 | + | return out, nil | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | func TestRepositoryActionDownload(t *testing.T) { | |
| 557 | + | forge := newForge(t, map[string][]byte{ | |
| 558 | + | "/tarball/octo/tool/v1": tarOf(t, "octo-tool-aaaa", files{"action.yml": nodeAction, "main.sh": "echo \"repo=$GITHUB_ACTION_REPOSITORY ref=$GITHUB_ACTION_REPOSITORY@$GITHUB_ACTION_REF dir=$GITHUB_ACTION_PATH\"\n"}), | |
| 559 | + | }) | |
| 560 | + | rec, res := actionRun(t, files{}, " - uses: octo/tool@v1\n - uses: octo/tool@v1\n", func(c *Config) { | |
| 561 | + | c.Actions.Resolver = forge.resolver("archive-secret-token") | |
| 562 | + | c.Actions.Backoff = func() time.Duration { return time.Millisecond } | |
| 563 | + | }) | |
| 564 | + | if res.Conclusion != ConclusionSuccess { | |
| 565 | + | t.Fatalf("%+v\n%s", res, logOf(rec.steps[0])) | |
| 566 | + | } | |
| 567 | + | setup := logOf(rec.steps[0]) | |
| 568 | + | if !strings.Contains(setup, "Getting action download info\nDownload action repository 'octo/tool@v1' (SHA:"+strings.Repeat("a", 40)+")") { | |
| 569 | + | t.Errorf("set up:\n%s", setup) | |
| 570 | + | } | |
| 571 | + | if strings.Count(setup, "Download action repository") != 1 || forge.hits.Load() != 1 { | |
| 572 | + | t.Errorf("an action is downloaded once per job (hits %d):\n%s", forge.hits.Load(), setup) | |
| 573 | + | } | |
| 574 | + | if len(forge.asked) != 1 || len(forge.asked[0]) != 1 { | |
| 575 | + | t.Errorf("the same action is resolved once: %v", forge.asked) | |
| 576 | + | } | |
| 577 | + | if got := forge.auth["/tarball/octo/tool/v1"]; got != "Basic eC1hY2Nlc3MtdG9rZW46YXJjaGl2ZS1zZWNyZXQtdG9rZW4=" { | |
| 578 | + | t.Errorf("auth header %q", got) | |
| 579 | + | } | |
| 580 | + | if got := logOf(rec.steps[1]); !strings.Contains(got, "repo=octo/tool ref=octo/tool@v1") || !strings.Contains(got, "##[group]Run octo/tool@v1") { | |
| 581 | + | t.Errorf("run:\n%s", got) | |
| 582 | + | } | |
| 583 | + | if rec.steps[1].Name != "Run octo/tool@v1" { | |
| 584 | + | t.Errorf("name %q", rec.steps[1].Name) | |
| 585 | + | } | |
| 586 | + | for _, s := range rec.steps { | |
| 587 | + | if strings.Contains(logOf(s), "archive-secret-token") { | |
| 588 | + | t.Errorf("the archive token leaked in %q", s.Name) | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | func TestRepositoryActionSubpathAndErrors(t *testing.T) { | |
| 594 | + | forge := newForge(t, map[string][]byte{ | |
| 595 | + | "/tarball/octo/mono/v2": tarOf(t, "octo-mono-bbbb", files{"sub/dir/action.yml": nodeAction, "sub/dir/main.sh": "echo sub-ok\n"}), | |
| 596 | + | }) | |
| 597 | + | cfg := func(c *Config) { | |
| 598 | + | c.Actions.Resolver = forge.resolver("") | |
| 599 | + | c.Actions.Backoff = func() time.Duration { return time.Millisecond } | |
| 600 | + | } | |
| 601 | + | rec, res := actionRun(t, files{}, " - uses: octo/mono/sub/dir@v2\n", cfg) | |
| 602 | + | if res.Conclusion != ConclusionSuccess || !strings.Contains(logOf(rec.steps[1]), "sub-ok") || rec.steps[1].Name != "Run octo/mono/sub/dir@v2" { | |
| 603 | + | t.Fatalf("%+v %v\n%s", res, rec.names(), logOf(rec.steps[1])) | |
| 604 | + | } | |
| 605 | + | // A repository the server cannot resolve: its message ends "Set up job". | |
| 606 | + | rec, res = actionRun(t, files{}, " - uses: octo/missing@v1\n - run: echo NO\n", cfg) | |
| 607 | + | if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 || !strings.Contains(logOf(rec.steps[0]), "##[error]Unable to resolve action `octo/missing@v1`, repository not found: `octo/missing`") { | |
| 608 | + | t.Fatalf("%+v %v\n%s", res, rec.names(), logOf(rec.steps[0])) | |
| 609 | + | } | |
| 610 | + | // An archive that does not exist: 404 is not retried. | |
| 611 | + | rec, res = actionRun(t, files{}, " - uses: octo/gone@v1\n", cfg) | |
| 612 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "##[error]An action could not be found at the URI '"+forge.srv.URL+"/tarball/octo/gone/v1'") { | |
| 613 | + | t.Fatalf("%+v\n%s", res, logOf(rec.steps[0])) | |
| 614 | + | } | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | func TestDownloadRetriesAndWarns(t *testing.T) { | |
| 618 | + | var n atomic.Int32 | |
| 619 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n.Add(1); http.Error(w, "x", 500) })) | |
| 620 | + | defer srv.Close() | |
| 621 | + | res := func(context.Context, []string) ([]ResolvedAction, error) { | |
| 622 | + | return []ResolvedAction{{Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSHA: strings.Repeat("c", 40), TarballURL: srv.URL + "/t"}}, nil | |
| 623 | + | } | |
| 624 | + | rec, r := actionRun(t, files{}, " - uses: a/b@v1\n", func(c *Config) { | |
| 625 | + | c.Actions.Resolver = res | |
| 626 | + | c.Actions.Backoff = func() time.Duration { return time.Millisecond } | |
| 627 | + | }) | |
| 628 | + | if r.Conclusion != ConclusionFailure || n.Load() != 3 { | |
| 629 | + | t.Fatalf("%+v attempts %d", r, n.Load()) | |
| 630 | + | } | |
| 631 | + | got := logOf(rec.steps[0]) | |
| 632 | + | if strings.Count(got, "##[warning]Failed to download action '"+srv.URL+"/t'. Error: Response status code does not indicate success: 500 (Internal Server Error). ") != 2 || | |
| 633 | + | strings.Count(got, "##[warning]Back off 0.001 seconds before retry.") != 2 { | |
| 634 | + | t.Errorf("%s", got) | |
| 635 | + | } | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | func TestMissingManifestInDownloadedAction(t *testing.T) { | |
| 639 | + | forge := newForge(t, map[string][]byte{"/tarball/octo/empty/v1": tarOf(t, "octo-empty-cccc", files{"README.md": "x"})}) | |
| 640 | + | rec, res := actionRun(t, files{}, " - uses: octo/empty@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") }) | |
| 641 | + | if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' for action 'octo/empty@v1'.") { | |
| 642 | + | t.Fatalf("%+v\n%s", res, logOf(rec.steps[0])) | |
| 643 | + | } | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | func TestRepositoryActionPrePostAndState(t *testing.T) { | |
| 647 | + | act := "name: pp\nruns:\n using: node20\n pre: pre.sh\n main: main.sh\n post: post.sh\n pre-if: always()\n post-if: success()\n" | |
| 648 | + | sc := "echo \"[$P] state=[$STATE_k]\"\necho \"::save-state name=k::from-$P\"\n" | |
| 649 | + | forge := newForge(t, map[string][]byte{"/tarball/o/pp/v1": tarOf(t, "o-pp-x", files{ | |
| 650 | + | "action.yml": act, "pre.sh": "P=pre\n" + sc, "main.sh": "P=main\n" + sc, "post.sh": "P=post\n" + sc})}) | |
| 651 | + | rec, res := actionRun(t, files{}, " - run: echo first\n - uses: o/pp@v1\n - run: echo last\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") }) | |
| 652 | + | if res.Conclusion != ConclusionSuccess { | |
| 653 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 654 | + | } | |
| 655 | + | want := "Set up job|Pre Run o/pp@v1|Run echo first|Run o/pp@v1|Run echo last|Post Run o/pp@v1|Complete job" | |
| 656 | + | if got := strings.Join(rec.names(), "|"); got != want { | |
| 657 | + | t.Fatalf("%s", got) | |
| 658 | + | } | |
| 659 | + | if got := logOf(rec.steps[1]); !strings.Contains(got, "[pre] state=[]") || !strings.Contains(got, "##[group]Run o/pp@v1") { | |
| 660 | + | t.Errorf("pre:\n%s", got) | |
| 661 | + | } | |
| 662 | + | if got := logOf(rec.steps[3]); !strings.Contains(got, "[main] state=[from-pre]") { | |
| 663 | + | t.Errorf("main sees the state of pre:\n%s", got) | |
| 664 | + | } | |
| 665 | + | if got := logOf(rec.steps[5]); !strings.Contains(got, "Post job cleanup.") || !strings.Contains(got, "[post] state=[from-main]") { | |
| 666 | + | t.Errorf("post:\n%s", got) | |
| 667 | + | } | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | func TestDockerPrePostEntrypoints(t *testing.T) { | |
| 671 | + | forge := newForge(t, map[string][]byte{"/tarball/o/dk/v1": tarOf(t, "o-dk-x", files{ | |
| 672 | + | "action.yml": "name: dk\nruns:\n using: docker\n image: docker://alpine:3\n pre-entrypoint: /pre.sh\n entrypoint: /main.sh\n post-entrypoint: /post.sh\n"})}) | |
| 673 | + | d, lf := fakeDocker(t, 0) | |
| 674 | + | rec, res := actionRun(t, files{}, " - uses: o/dk@v1\n", func(c *Config) { | |
| 675 | + | c.Actions.DockerPath = d | |
| 676 | + | c.Actions.Resolver = forge.resolver("") | |
| 677 | + | }) | |
| 678 | + | if res.Conclusion != ConclusionSuccess { | |
| 679 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 680 | + | } | |
| 681 | + | want := "Set up job|Pull alpine:3|Pre Run o/dk@v1|Run o/dk@v1|Post Run o/dk@v1|Complete job" | |
| 682 | + | if got := strings.Join(rec.names(), "|"); got != want { | |
| 683 | + | t.Fatalf("%s", got) | |
| 684 | + | } | |
| 685 | + | var eps []string | |
| 686 | + | for _, c := range calls(t, lf) { | |
| 687 | + | if c[0] != "run" { | |
| 688 | + | continue | |
| 689 | + | } | |
| 690 | + | for i, a := range c { | |
| 691 | + | if a == "--entrypoint" { | |
| 692 | + | eps = append(eps, c[i+1]) | |
| 693 | + | } | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | if strings.Join(eps, ",") != "/pre.sh,/main.sh,/post.sh" { | |
| 697 | + | t.Errorf("entrypoints %v", eps) | |
| 698 | + | } | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | func TestRepositoryActionInputsAreMaskedAndTokenNotInEnv(t *testing.T) { | |
| 702 | + | forge := newForge(t, map[string][]byte{"/tarball/o/t/v1": tarOf(t, "o-t-x", files{ | |
| 703 | + | "action.yml": nodeAction, "main.sh": "env | grep -c archive-secret || true\n"})}) | |
| 704 | + | rec, res := actionRun(t, files{}, " - uses: o/t@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver("archive-secret") }) | |
| 705 | + | if res.Conclusion != ConclusionSuccess { | |
| 706 | + | t.Fatalf("%+v", res) | |
| 707 | + | } | |
| 708 | + | if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "\n0") { | |
| 709 | + | t.Errorf("the archive token is not in the environment of actions:\n%s", got) | |
| 710 | + | } | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | func tokenOrigin(token, u string) string { | |
| 714 | + | if token == "" { | |
| 715 | + | return "" | |
| 716 | + | } | |
| 717 | + | return originOf(u) | |
| 718 | + | } | |
| 719 | + | ||
| 720 | + | // GO-002: the token goes to the origin the host named and nowhere else, and | |
| 721 | + | // a redirect never carries it to another origin. | |
| 722 | + | func TestArchiveTokenOnlyToTheNamedOrigin(t *testing.T) { | |
| 723 | + | var gotAuth atomic.Value | |
| 724 | + | other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 725 | + | gotAuth.Store(r.Header.Get("Authorization")) | |
| 726 | + | _, _ = w.Write(tarOf(t, "o-x-1", files{"action.yml": nodeAction, "main.sh": "echo ok\n"})) | |
| 727 | + | })) | |
| 728 | + | defer other.Close() | |
| 729 | + | var firstAuth atomic.Value | |
| 730 | + | redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 731 | + | firstAuth.Store(r.Header.Get("Authorization")) | |
| 732 | + | http.Redirect(w, r, other.URL+"/t", http.StatusFound) | |
| 733 | + | })) | |
| 734 | + | defer redir.Close() | |
| 735 | + | mk := func(u, token, origin string) Resolver { | |
| 736 | + | return func(context.Context, []string) ([]ResolvedAction, error) { | |
| 737 | + | return []ResolvedAction{{Ref: "o/x@v1", NameWithOwner: "o/x", ResolvedSHA: strings.Repeat("d", 40), TarballURL: u, Token: token, AuthOrigin: origin}}, nil | |
| 738 | + | } | |
| 739 | + | } | |
| 740 | + | run := func(r Resolver) Result { | |
| 741 | + | _, res := actionRun(t, files{}, " - uses: o/x@v1\n", func(c *Config) { | |
| 742 | + | c.Actions.Resolver = r | |
| 743 | + | c.Actions.Backoff = func() time.Duration { return time.Millisecond } | |
| 744 | + | }) | |
| 745 | + | return res | |
| 746 | + | } | |
| 747 | + | // Redirect to another origin (another port on the same host): the header is | |
| 748 | + | // sent to the first hop and dropped for the second. | |
| 749 | + | if res := run(mk(redir.URL+"/t", "tok", originOf(redir.URL))); res.Conclusion != ConclusionSuccess { | |
| 750 | + | t.Fatalf("%+v", res) | |
| 751 | + | } | |
| 752 | + | if firstAuth.Load() == "" || gotAuth.Load() != "" { | |
| 753 | + | t.Errorf("first hop %q, second hop %q", firstAuth.Load(), gotAuth.Load()) | |
| 754 | + | } | |
| 755 | + | // A token without an AuthOrigin, or for another origin, is never sent. | |
| 756 | + | gotAuth.Store("unset") | |
| 757 | + | if res := run(mk(other.URL+"/t", "tok", "")); res.Conclusion != ConclusionSuccess { | |
| 758 | + | t.Fatalf("%+v", res) | |
| 759 | + | } | |
| 760 | + | if gotAuth.Load() != "" { | |
| 761 | + | t.Errorf("no AuthOrigin: sent %q", gotAuth.Load()) | |
| 762 | + | } | |
| 763 | + | gotAuth.Store("unset") | |
| 764 | + | _ = run(mk(other.URL+"/t", "tok", "https://other.example")) | |
| 765 | + | if gotAuth.Load() != "" { | |
| 766 | + | t.Errorf("another origin: sent %q", gotAuth.Load()) | |
| 767 | + | } | |
| 768 | + | // Without a token (github.com) nothing is sent either. | |
| 769 | + | gotAuth.Store("unset") | |
| 770 | + | _ = run(mk(other.URL+"/t", "", "")) | |
| 771 | + | if gotAuth.Load() != "" { | |
| 772 | + | t.Errorf("anonymous: sent %q", gotAuth.Load()) | |
| 773 | + | } | |
| 774 | + | // The named origin, with the token. | |
| 775 | + | gotAuth.Store("unset") | |
| 776 | + | _ = run(mk(other.URL+"/t", "tok", originOf(other.URL))) | |
| 777 | + | if got, _ := gotAuth.Load().(string); !strings.HasPrefix(got, "Basic ") { | |
| 778 | + | t.Errorf("named origin: %q", got) | |
| 779 | + | } | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | func TestArchiveRedirectPolicy(t *testing.T) { | |
| 783 | + | j := &job{} | |
| 784 | + | c := j.archiveClient() | |
| 785 | + | mkreq := func(u string) *http.Request { | |
| 786 | + | r, _ := http.NewRequest(http.MethodGet, u, nil) | |
| 787 | + | r.Header.Set("Authorization", "Basic x") | |
| 788 | + | return r | |
| 789 | + | } | |
| 790 | + | for _, tc := range []struct { | |
| 791 | + | from, to string | |
| 792 | + | keep bool | |
| 793 | + | refused bool | |
| 794 | + | }{ | |
| 795 | + | {"https://a.example/x", "https://a.example/y", true, false}, | |
| 796 | + | {"https://a.example/x", "https://cdn.a.example/y", false, false}, // subdomain | |
| 797 | + | {"https://a.example/x", "https://a.example:8443/y", false, false}, // port | |
| 798 | + | {"https://a.example/x", "http://a.example/y", false, true}, // downgrade | |
| 799 | + | {"http://a.example/x", "https://a.example/y", false, false}, // scheme change | |
| 800 | + | {"https://a.example:443/x", "https://a.example/y", true, false}, // same origin | |
| 801 | + | {"https://A.example/x", "https://a.example/y", true, false}, | |
| 802 | + | } { | |
| 803 | + | req := mkreq(tc.to) | |
| 804 | + | err := c.CheckRedirect(req, []*http.Request{mkreq(tc.from)}) | |
| 805 | + | if (err != nil) != tc.refused || (req.Header.Get("Authorization") != "") != tc.keep && !tc.refused { | |
| 806 | + | t.Errorf("%s -> %s: err=%v header=%q", tc.from, tc.to, err, req.Header.Get("Authorization")) | |
| 807 | + | } | |
| 808 | + | } | |
| 809 | + | if err := c.CheckRedirect(mkreq("https://a.example/"), make([]*http.Request, 10)); err == nil { | |
| 810 | + | t.Error("redirect loops must stop") | |
| 811 | + | } | |
| 812 | + | } | |
| 813 | + | ||
| 814 | + | // GO-008: Go rebuilds the headers of a redirected request from the first | |
| 815 | + | // request, so a chain that leaves the origin and stays on the new one, or comes | |
| 816 | + | // back, would get the Authorization header again on the third request. Real | |
| 817 | + | // servers, real redirects. | |
| 818 | + | func TestArchiveRedirectChainNeverReauthorises(t *testing.T) { | |
| 819 | + | type hit struct{ path, auth string } | |
| 820 | + | var mu sync.Mutex | |
| 821 | + | var hits []hit | |
| 822 | + | record := func(r *http.Request) { | |
| 823 | + | mu.Lock() | |
| 824 | + | hits = append(hits, hit{r.URL.Path, r.Header.Get("Authorization")}) | |
| 825 | + | mu.Unlock() | |
| 826 | + | } | |
| 827 | + | var a, b *httptest.Server | |
| 828 | + | // a (the authorised origin) and b are two servers on the same host and | |
| 829 | + | // different ports: the case of Go's host-only comparison. | |
| 830 | + | a = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 831 | + | record(r) | |
| 832 | + | switch r.URL.Path { | |
| 833 | + | case "/start": | |
| 834 | + | http.Redirect(w, r, b.URL+"/first", http.StatusFound) | |
| 835 | + | case "/back": | |
| 836 | + | _, _ = w.Write(tarOf(t, "o-x-1", files{"action.yml": nodeAction, "main.sh": "echo ok\n"})) | |
| 837 | + | } | |
| 838 | + | })) | |
| 839 | + | defer a.Close() | |
| 840 | + | b = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 841 | + | record(r) | |
| 842 | + | switch r.URL.Path { | |
| 843 | + | case "/first": | |
| 844 | + | http.Redirect(w, r, b.URL+"/second", http.StatusFound) | |
| 845 | + | case "/second": | |
| 846 | + | http.Redirect(w, r, a.URL+"/back", http.StatusFound) // and back to the origin | |
| 847 | + | } | |
| 848 | + | })) | |
| 849 | + | defer b.Close() | |
| 850 | + | res := func(context.Context, []string) ([]ResolvedAction, error) { | |
| 851 | + | return []ResolvedAction{{Ref: "o/x@v1", NameWithOwner: "o/x", ResolvedSHA: strings.Repeat("e", 40), TarballURL: a.URL + "/start", Token: "tok", AuthOrigin: originOf(a.URL)}}, nil | |
| 852 | + | } | |
| 853 | + | _, r := actionRun(t, files{}, " - uses: o/x@v1\n", func(c *Config) { c.Actions.Resolver = res }) | |
| 854 | + | if r.Conclusion != ConclusionSuccess { | |
| 855 | + | t.Fatalf("%+v", r) | |
| 856 | + | } | |
| 857 | + | want := []hit{{"/start", "set"}, {"/first", ""}, {"/second", ""}, {"/back", ""}} | |
| 858 | + | mu.Lock() | |
| 859 | + | defer mu.Unlock() | |
| 860 | + | if len(hits) != len(want) { | |
| 861 | + | t.Fatalf("hits %v", hits) | |
| 862 | + | } | |
| 863 | + | for i, h := range hits { | |
| 864 | + | if h.path != want[i].path || (h.auth != "") != (want[i].auth != "") { | |
| 865 | + | t.Errorf("hit %d: %+v, want %+v (auth %q)", i, h, want[i], h.auth) | |
| 866 | + | } | |
| 867 | + | } | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | // GO-012: the token goes to the exact origin, in canonical form. | |
| 871 | + | func TestTokenAllowedOrigins(t *testing.T) { | |
| 872 | + | for _, tc := range []struct { | |
| 873 | + | url, origin string | |
| 874 | + | ok bool | |
| 875 | + | }{ | |
| 876 | + | {"https://ermite.example/x", "https://ermite.example", true}, | |
| 877 | + | {"https://ermite.example:443/x", "https://ermite.example", true}, // default port, either way | |
| 878 | + | {"https://ermite.example/x", "https://ermite.example:443", true}, | |
| 879 | + | {"https://ERMITE.example/x", "https://ermite.example", true}, | |
| 880 | + | {"https://[::1]:8443/x", "https://[::1]:8443", true}, | |
| 881 | + | {"https://[::1]:8443/x", "https://[::1:8443]", false}, // the collision of the review | |
| 882 | + | {"https://[::1:8443]/x", "https://[::1]:8443", false}, | |
| 883 | + | {"https://[::1]/x", "https://[::1]:443", true}, | |
| 884 | + | {"https://[::1]/x", "https://[::1]:8443", false}, | |
| 885 | + | {"http://ermite.example/x", "https://ermite.example", false}, | |
| 886 | + | {"https://ermite.example:8443/x", "https://ermite.example", false}, | |
| 887 | + | {"https://sub.ermite.example/x", "https://ermite.example", false}, | |
| 888 | + | {"https://ermite.example/x", "", false}, | |
| 889 | + | {"https://ermite.example/x", "not an origin", false}, | |
| 890 | + | {"https://u:p@ermite.example/x", "https://ermite.example", false}, | |
| 891 | + | } { | |
| 892 | + | if got := tokenAllowed(ResolvedAction{Token: "t", TarballURL: tc.url, AuthOrigin: tc.origin}); got != tc.ok { | |
| 893 | + | t.Errorf("url %q origin %q: %v, want %v", tc.url, tc.origin, got, tc.ok) | |
| 894 | + | } | |
| 895 | + | } | |
| 896 | + | if tokenAllowed(ResolvedAction{TarballURL: "https://a.test/x", AuthOrigin: "https://a.test"}) { | |
| 897 | + | t.Error("no token, nothing to allow") | |
| 898 | + | } | |
| 899 | + | } | |
A internal/executor/actions_types.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "strconv" | |
| 9 | + | "strings" | |
| 10 | + | "sync/atomic" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite-actions/action" | |
| 14 | + | "bnema.dev/ermite-actions/workflow" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // ResolvedAction is where to download one action (ermite.ResolvedAction, see | |
| 18 | + | // the host). The Token is a secret: the executor masks it. | |
| 19 | + | type ResolvedAction struct { | |
| 20 | + | Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string | |
| 21 | + | // AuthOrigin is the only origin (scheme://host[:port]) the Token is sent to; | |
| 22 | + | // the host sets it. Without it the token is not sent. | |
| 23 | + | AuthOrigin string | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It | |
| 27 | + | // is the host's call (the job token stays out of the sandbox); the result has | |
| 28 | + | // one entry per value, in order. | |
| 29 | + | type Resolver func(ctx context.Context, uses []string) ([]ResolvedAction, error) | |
| 30 | + | ||
| 31 | + | // ActionsConfig is what the executor needs to run actions. | |
| 32 | + | type ActionsConfig struct { | |
| 33 | + | // Resolver is required to run actions from repositories; without it a job | |
| 34 | + | // that uses one fails "Set up job". | |
| 35 | + | Resolver Resolver | |
| 36 | + | // HTTPClient downloads the archives; nil means a client with the default | |
| 37 | + | // transport. It must not follow a redirect to another host with the | |
| 38 | + | // credentials (net/http does not). | |
| 39 | + | HTTPClient *http.Client | |
| 40 | + | // ExternalsDir is where Node.js lives (<dir>/node20/bin/node); empty means | |
| 41 | + | // /home/runner/externals. | |
| 42 | + | ExternalsDir string | |
| 43 | + | // DockerPath is the docker CLI; empty means the one found in PATH. | |
| 44 | + | DockerPath string | |
| 45 | + | // Node24ByDefault is the server flag actions.runner.usenode24bydefault | |
| 46 | + | // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no | |
| 47 | + | // feature flags, so the default is pinned here. | |
| 48 | + | Node24ByDefault bool | |
| 49 | + | // Backoff, when set, replaces the random 10 to 30 s wait between download | |
| 50 | + | // attempts (tests). | |
| 51 | + | Backoff func() time.Duration | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | const defaultExternalsDir = "/home/runner/externals" | |
| 55 | + | ||
| 56 | + | // stage is ActionRunStage. | |
| 57 | + | type stage int | |
| 58 | + | ||
| 59 | + | const ( | |
| 60 | + | stageMain stage = iota | |
| 61 | + | stagePre | |
| 62 | + | stagePost | |
| 63 | + | ) | |
| 64 | + | ||
| 65 | + | // compositeMaxDepth is Constants.CompositeActionsMaxDepth | |
| 66 | + | // (Runner.Common/Constants.cs:46). | |
| 67 | + | const compositeMaxDepth = 9 | |
| 68 | + | ||
| 69 | + | // kind is what an action runs. | |
| 70 | + | type kind int | |
| 71 | + | ||
| 72 | + | const ( | |
| 73 | + | kindScript kind = iota | |
| 74 | + | kindNode | |
| 75 | + | kindDocker | |
| 76 | + | kindComposite | |
| 77 | + | ) | |
| 78 | + | ||
| 79 | + | // actionDef is a loaded action (ActionManager.LoadAction's Definition). | |
| 80 | + | type actionDef struct { | |
| 81 | + | kind kind | |
| 82 | + | // dir is the directory of the action ("" for docker://). | |
| 83 | + | dir string | |
| 84 | + | act *action.Action // nil for docker:// and for a Dockerfile without manifest | |
| 85 | + | // image is the "runs.image" of a container action, or the image of a | |
| 86 | + | // docker:// step; "" for Dockerfile-only actions (see dockerfile). | |
| 87 | + | image string | |
| 88 | + | // dockerfile is the path of the Dockerfile of a Dockerfile-only action. | |
| 89 | + | dockerfile string | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (d *actionDef) runs() *action.Runs { | |
| 93 | + | if d.act == nil { | |
| 94 | + | return nil | |
| 95 | + | } | |
| 96 | + | return d.act.Runs | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // preIf and postIf are InitCondition and CleanupCondition: always() unless the | |
| 100 | + | // manifest says otherwise (ActionExecutionData, ActionManager.cs:1884-1900). | |
| 101 | + | func (d *actionDef) preIf() string { return condOrAlways(d.runs(), true) } | |
| 102 | + | func (d *actionDef) postIf() string { return condOrAlways(d.runs(), false) } | |
| 103 | + | ||
| 104 | + | func condOrAlways(r *action.Runs, pre bool) string { | |
| 105 | + | if r == nil || r.Kind == action.RunsComposite { | |
| 106 | + | return "always()" | |
| 107 | + | } | |
| 108 | + | c := r.PostIf | |
| 109 | + | if pre { | |
| 110 | + | c = r.PreIf | |
| 111 | + | } | |
| 112 | + | if strings.TrimSpace(c) == "" { | |
| 113 | + | return "always()" | |
| 114 | + | } | |
| 115 | + | return c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // node is one step of the job or of a composite action, with what the runner | |
| 119 | + | // keeps per action step: its intra-action state, and for a composite its | |
| 120 | + | // children and its pre and post steps (CompositeActionExecutionData). | |
| 121 | + | type node struct { | |
| 122 | + | st *workflow.Step | |
| 123 | + | uses action.Uses | |
| 124 | + | parent *node | |
| 125 | + | // scope is the name of the steps context of the step's children: the | |
| 126 | + | // fully qualified context name (ExecutionContext.GetFullyQualifiedContextName). | |
| 127 | + | scope string | |
| 128 | + | // stateKey names the intra-action state of the action (job.saveState): the | |
| 129 | + | // pre, main and post stages of an action, and its inner steps, share it | |
| 130 | + | // (the runner keys it by the id of the step, FileCommandManager.cs:305-318). | |
| 131 | + | stateKey string | |
| 132 | + | // children are the nodes of a composite action's steps. | |
| 133 | + | children []*node | |
| 134 | + | // preSteps and postSteps are the embedded steps with a pre or a post, in | |
| 135 | + | // declaration order (the runner's post steps are a stack: they run last | |
| 136 | + | // to first). | |
| 137 | + | preSteps, postSteps []*node | |
| 138 | + | // image is the image the setup steps pulled or built for the step. | |
| 139 | + | image string | |
| 140 | + | def *actionDef | |
| 141 | + | // postRegistered is Root.StepsWithPostRegistered / EmbeddedStepsWithPostRegistered. | |
| 142 | + | postRegistered bool | |
| 143 | + | postCond string | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // nodeSeq numbers the nodes, for their state keys. | |
| 147 | + | var nodeSeq atomic.Int64 | |
| 148 | + | ||
| 149 | + | // newNodes makes the nodes of steps. The workflow reader has checked every | |
| 150 | + | // uses value. | |
| 151 | + | func newNodes(steps []*workflow.Step, parent *node, scope string) ([]*node, error) { | |
| 152 | + | out := make([]*node, 0, len(steps)) | |
| 153 | + | for _, st := range steps { | |
| 154 | + | n := &node{st: st, parent: parent, stateKey: "node-" + strconv.FormatInt(nodeSeq.Add(1), 10)} | |
| 155 | + | n.scope = st.ID | |
| 156 | + | if scope != "" { | |
| 157 | + | n.scope = scope + "." + st.ID | |
| 158 | + | } | |
| 159 | + | if st.Uses != nil { | |
| 160 | + | u, err := action.ParseUses(st.Uses.Value) | |
| 161 | + | if err != nil { | |
| 162 | + | return nil, err | |
| 163 | + | } | |
| 164 | + | n.uses = u | |
| 165 | + | } | |
| 166 | + | out = append(out, n) | |
| 167 | + | } | |
| 168 | + | return out, nil | |
| 169 | + | } | |
M internal/executor/contexts.go +28 -12
| @@ -33,8 +33,15 @@ func (o *ordered) Set(k, v string) { | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | func (o *ordered) Get(k string) (string, bool) { v, ok := o.vals[k]; return v, ok } | |
| 36 | - | func (o *ordered) Len() int { return len(o.keys) } | |
| 37 | - | func (o *ordered) Keys() []string { return slices.Clone(o.keys) } | |
| 36 | + | func (o *ordered) Remove(k string) { | |
| 37 | + | if _, ok := o.vals[k]; !ok { | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | delete(o.vals, k) | |
| 41 | + | o.keys = slices.DeleteFunc(o.keys, func(x string) bool { return x == k }) | |
| 42 | + | } | |
| 43 | + | func (o *ordered) Len() int { return len(o.keys) } | |
| 44 | + | func (o *ordered) Keys() []string { return slices.Clone(o.keys) } | |
| 38 | 45 | ||
| 39 | 46 | func (o *ordered) Clone() *ordered { | |
| 40 | 47 | c := newOrdered() | |
| @@ -130,6 +137,8 @@ type Layout struct { | |||
| 130 | 137 | RunnerWorkspace string | |
| 131 | 138 | // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>. | |
| 132 | 139 | Workspace string | |
| 140 | + | // Actions is where the actions of the job are downloaded: <WorkRoot>/_actions. | |
| 141 | + | Actions string | |
| 133 | 142 | } | |
| 134 | 143 | ||
| 135 | 144 | // DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image. | |
| @@ -145,6 +154,7 @@ func NewLayout(home, repo, toolCache string) Layout { | |||
| 145 | 154 | return Layout{ | |
| 146 | 155 | Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache, | |
| 147 | 156 | RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo), | |
| 157 | + | Actions: filepath.Join(work, "_actions"), | |
| 148 | 158 | } | |
| 149 | 159 | } | |
| 150 | 160 | ||
| @@ -210,9 +220,10 @@ type State struct { | |||
| 210 | 220 | // env is Global.EnvironmentVariables: the job-level environment and what | |
| 211 | 221 | // GITHUB_ENV adds. A step's own env is not in it. | |
| 212 | 222 | env *ordered | |
| 213 | - | // steps is the steps context, in order of first mention. | |
| 214 | - | stepOrder []string | |
| 215 | - | steps map[string]*stepCtx | |
| 223 | + | // root is the steps context of the job; cur is the one in use: a composite | |
| 224 | + | // action's steps see their own (see PushScope). | |
| 225 | + | root, cur *stepsScope | |
| 226 | + | scopes map[string]*stepsScope | |
| 216 | 227 | // prepend is Global.PrependPath in the order of the GITHUB_PATH entries. | |
| 217 | 228 | prepend []string | |
| 218 | 229 | // status is job.status: "success", "failure" or "cancelled". | |
| @@ -239,7 +250,9 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac | |||
| 239 | 250 | } | |
| 240 | 251 | root = d | |
| 241 | 252 | } | |
| 242 | - | s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"} | |
| 253 | + | s := &State{other: map[string]expr.Value{}, env: newOrdered(), status: "success", scopes: map[string]*stepsScope{}} | |
| 254 | + | s.root = newScope() | |
| 255 | + | s.cur = s.root | |
| 243 | 256 | ||
| 244 | 257 | // github: token first, then what the server gave (ExecutionContext.cs:1023-1037). | |
| 245 | 258 | // The token is the one the server sent in github.token (GitHub's | |
| @@ -404,11 +417,11 @@ func (s *State) Status() string { return s.status } | |||
| 404 | 417 | func stepIDVisible(id string) bool { return id != "" && !strings.HasPrefix(id, "__") } | |
| 405 | 418 | ||
| 406 | 419 | func (s *State) step(id string) *stepCtx { | |
| 407 | - | c, ok := s.steps[id] | |
| 420 | + | c, ok := s.cur.steps[id] | |
| 408 | 421 | if !ok { | |
| 409 | 422 | c = &stepCtx{outputs: newOrdered()} | |
| 410 | - | s.steps[id] = c | |
| 411 | - | s.stepOrder = append(s.stepOrder, id) | |
| 423 | + | s.cur.steps[id] = c | |
| 424 | + | s.cur.order = append(s.cur.order, id) | |
| 412 | 425 | } | |
| 413 | 426 | return c | |
| 414 | 427 | } | |
| @@ -434,7 +447,7 @@ func (s *State) SetStepResult(id, outcome, conclusion string) { | |||
| 434 | 447 | ||
| 435 | 448 | // Output returns an output of a step ("" when absent). | |
| 436 | 449 | func (s *State) Output(id, name string) string { | |
| 437 | - | if c, ok := s.steps[id]; ok { | |
| 450 | + | if c, ok := s.cur.steps[id]; ok { | |
| 438 | 451 | v, _ := c.outputs.Get(name) | |
| 439 | 452 | return v | |
| 440 | 453 | } | |
| @@ -443,8 +456,8 @@ func (s *State) Output(id, name string) string { | |||
| 443 | 456 | ||
| 444 | 457 | func (s *State) stepsValue() *expr.Dictionary { | |
| 445 | 458 | d := expr.NewDictionary() | |
| 446 | - | for _, id := range s.stepOrder { | |
| 447 | - | c := s.steps[id] | |
| 459 | + | for _, id := range s.cur.order { | |
| 460 | + | c := s.cur.steps[id] | |
| 448 | 461 | sd := expr.NewDictionary() | |
| 449 | 462 | sd.Add("outputs", c.outputs.Dict()) | |
| 450 | 463 | if c.outcome != "" { | |
| @@ -474,6 +487,9 @@ func (s *State) Values(env *ordered) map[string]expr.Value { | |||
| 474 | 487 | for k, v := range s.other { | |
| 475 | 488 | m[k] = v | |
| 476 | 489 | } | |
| 490 | + | if s.cur.inputs != nil { | |
| 491 | + | m["inputs"] = s.cur.inputs | |
| 492 | + | } | |
| 477 | 493 | return m | |
| 478 | 494 | } | |
| 479 | 495 | ||
M internal/executor/exec_step.go +37 -20
| @@ -6,20 +6,13 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "strings" | |
| 9 | - | ||
| 10 | - | "bnema.dev/ermite-actions/workflow" | |
| 11 | 9 | ) | |
| 12 | 10 | ||
| 13 | 11 | // execute is ActionRunner.RunAsync plus ScriptHandler for one run step. | |
| 14 | 12 | func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |
| 15 | 13 | num, st := s.num, s.st | |
| 16 | - | if msg := notSupported(st); msg != "" { | |
| 17 | - | if st.Uses != nil { | |
| 18 | - | j.out(num, "##[group]Run "+st.Uses.Value) | |
| 19 | - | j.out(num, "##[endgroup]") | |
| 20 | - | } | |
| 21 | - | j.errorf(num, "%s", msg) | |
| 22 | - | return resFailed | |
| 14 | + | if st.Uses != nil { | |
| 15 | + | return j.runAction(s, stepCancel) | |
| 23 | 16 | } | |
| 24 | 17 | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | |
| 25 | 18 | failTemplate := func(err error) stepResult { | |
| @@ -27,11 +20,11 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||
| 27 | 20 | return resFailed | |
| 28 | 21 | } | |
| 29 | 22 | ||
| 30 | - | if err := j.prepareFileCommands(st); err != nil { | |
| 23 | + | if err := j.prepareFileCommands(s); err != nil { | |
| 31 | 24 | return fail(err) | |
| 32 | 25 | } | |
| 33 | 26 | // ProcessFiles runs after the handler, even when it failed (a finally block). | |
| 34 | - | defer j.fc.Process(s) | |
| 27 | + | defer s.fc.Process(s) | |
| 35 | 28 | ||
| 36 | 29 | in, err := j.scriptInputs(s) | |
| 37 | 30 | if err != nil { | |
| @@ -67,7 +60,10 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) { | |||
| 67 | 60 | if in.shell, err = j.evalString(st.Shell, s.env); err != nil { | |
| 68 | 61 | return in, err | |
| 69 | 62 | } | |
| 70 | - | if in.shell == "" { | |
| 63 | + | // defaults.run applies to the steps of the job only, not to the ones of a | |
| 64 | + | // composite action (ScriptHandler.cs:158-181: ScopeName is empty). | |
| 65 | + | top := s.parent == nil | |
| 66 | + | if in.shell == "" && top { | |
| 71 | 67 | in.shell = j.runDefaults.shell | |
| 72 | 68 | } | |
| 73 | 69 | if st.WorkingDirectory != nil { | |
| @@ -76,7 +72,7 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) { | |||
| 76 | 72 | return in, err | |
| 77 | 73 | } | |
| 78 | 74 | } | |
| 79 | - | if !in.hasWorkingDirectory { | |
| 75 | + | if !in.hasWorkingDirectory && top { | |
| 80 | 76 | in.workingDirectory = j.runDefaults.workingDirectory | |
| 81 | 77 | } | |
| 82 | 78 | return in, nil | |
| @@ -85,18 +81,25 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) { | |||
| 85 | 81 | // prepareFileCommands creates the files of the step, puts their paths in the | |
| 86 | 82 | // github context (FileCommandManager.InitializeFiles) and writes event.json | |
| 87 | 83 | // (ExecutionContext.WriteWebhookPayload). | |
| 88 | - | func (j *job) prepareFileCommands(st *workflow.Step) error { | |
| 84 | + | func (j *job) prepareFileCommands(s *stepRun) error { | |
| 89 | 85 | j.mu.Lock() | |
| 90 | 86 | defer j.mu.Unlock() | |
| 91 | - | files, err := j.fc.Init() | |
| 87 | + | if s.fc == nil { | |
| 88 | + | s.fc = j.fc | |
| 89 | + | } | |
| 90 | + | files, err := s.fc.Init() | |
| 92 | 91 | if err != nil { | |
| 93 | 92 | return err | |
| 94 | 93 | } | |
| 95 | 94 | for _, c := range fileCommands { | |
| 96 | 95 | j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)]) | |
| 97 | 96 | } | |
| 98 | - | j.state.SetGitHub("action_repository", "") | |
| 99 | - | j.state.SetGitHub("action_ref", "") | |
| 97 | + | if s.n == nil || s.n.st.Uses == nil { | |
| 98 | + | // ActionRunner.cs:149-165 sets them for every action step, a run step | |
| 99 | + | // included (its reference is a script, so they are empty). | |
| 100 | + | j.state.SetGitHub("action_repository", "") | |
| 101 | + | j.state.SetGitHub("action_ref", "") | |
| 102 | + | } | |
| 100 | 103 | if ev := j.state.EventJSON(); ev != "" { | |
| 101 | 104 | if p, err := j.writeEvent(ev); err == nil { | |
| 102 | 105 | j.state.SetGitHub("event_path", p) | |
| @@ -191,17 +194,26 @@ func (j *job) processEnv(stepEnv *ordered) []string { | |||
| 191 | 194 | // startProcess runs the script and turns how it ended into the result of the | |
| 192 | 195 | // step. | |
| 193 | 196 | func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult { | |
| 197 | + | return j.runProc(s, plan.CommandPath, dir, argv, "", stepCancel) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // runProc starts a process (exitDebug, when set, is the format of the debug line that | |
| 201 | + | // replaces the error about the exit code) for the step and streams its output through the | |
| 202 | + | // commands of the step. A script reports its exit code (ScriptHandler.cs: | |
| 203 | + | // 267-275); the handlers of actions only mark the step failed (NodeScriptAction | |
| 204 | + | // Handler.cs:178-184), a failing action says why itself. | |
| 205 | + | func (j *job) runProc(s *stepRun, shown, dir string, argv []string, exitDebug string, stepCancel <-chan struct{}) stepResult { | |
| 194 | 206 | num := s.num | |
| 195 | 207 | om := newOutputManager(s, stepCancel) | |
| 196 | 208 | defer om.dispose() | |
| 197 | 209 | res, err := runProcess(procSpec{ | |
| 198 | - | Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential, | |
| 210 | + | Argv: argv, Dir: dir, Env: s.procEnv(), Credential: j.cfg.Credential, | |
| 199 | 211 | OnLine: om.onData, | |
| 200 | 212 | Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, | |
| 201 | 213 | WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |
| 202 | 214 | }) | |
| 203 | 215 | if err != nil { | |
| 204 | - | j.errorf(num, "%s", startError(plan.CommandPath, dir, err)) | |
| 216 | + | j.errorf(num, "%s", startError(shown, dir, err)) | |
| 205 | 217 | return resFailed | |
| 206 | 218 | } | |
| 207 | 219 | if res.Cancelled { | |
| @@ -211,8 +223,13 @@ func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string | |||
| 211 | 223 | s.logCanceled() | |
| 212 | 224 | return resCanceled | |
| 213 | 225 | } | |
| 226 | + | if exitDebug != "" { | |
| 227 | + | j.debug(num, fmt.Sprintf(exitDebug, res.ExitCode)) | |
| 228 | + | } | |
| 214 | 229 | if res.ExitCode != 0 { | |
| 215 | - | j.errorf(num, "Process completed with exit code %d.", res.ExitCode) | |
| 230 | + | if exitDebug == "" { | |
| 231 | + | j.errorf(num, "Process completed with exit code %d.", res.ExitCode) | |
| 232 | + | } | |
| 216 | 233 | return resFailed | |
| 217 | 234 | } | |
| 218 | 235 | return resSucceeded | |
A internal/executor/filecmd_fork.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | // Fork returns file commands that share the directory and the root of f with | |
| 6 | + | // a suffix of their own: every action step has its own manager in the runner | |
| 7 | + | // (HostContext.CreateService<IFileCommandManager>(), ActionRunner.cs:173), so | |
| 8 | + | // the steps of a composite action do not delete the files of the step around | |
| 9 | + | // them. The fork must not be closed. | |
| 10 | + | func (f *FileCommands) Fork() *FileCommands { | |
| 11 | + | return &FileCommands{dir: f.dir, root: f.root, owner: f.owner} | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // Cleanup removes the files of the current step of a fork. | |
| 15 | + | func (f *FileCommands) Cleanup() { | |
| 16 | + | if f.suffix == "" { | |
| 17 | + | return | |
| 18 | + | } | |
| 19 | + | for _, c := range fileCommands { | |
| 20 | + | _ = f.root.Remove(f.name(c, f.suffix)) | |
| 21 | + | } | |
| 22 | + | } | |
M internal/executor/job.go +56 -6
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "os" | |
| 10 | 10 | "path/filepath" | |
| 11 | + | "slices" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "sync" | |
| 13 | 14 | "sync/atomic" | |
| @@ -75,6 +76,9 @@ type Config struct { | |||
| 75 | 76 | BaseEnv []string | |
| 76 | 77 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | |
| 77 | 78 | Debug bool | |
| 79 | + | // Actions configures the actions of the job (resolution, downloads, Node.js, | |
| 80 | + | // docker). | |
| 81 | + | Actions ActionsConfig | |
| 78 | 82 | // SendAnnotations and SendSummaries say that the server keeps annotations | |
| 79 | 83 | // and step summaries (features ci.annotations and ci.summaries): the | |
| 80 | 84 | // Reporter then gets them with StepResults. | |
| @@ -158,6 +162,13 @@ type job struct { | |||
| 158 | 162 | // masker hides the secrets in everything reported; it is set once the | |
| 159 | 163 | // contexts are built, before any step runs. | |
| 160 | 164 | masker atomic.Pointer[masker] | |
| 165 | + | // actions holds what the pre-job phase loaded (see prepareActions). | |
| 166 | + | actions *actionState | |
| 167 | + | // postStack is the post steps registered so far, last registered last. | |
| 168 | + | postStack []qstep | |
| 169 | + | // upgradedToNode24 are the actions that asked for node20 and got node24. | |
| 170 | + | upgradedToNode24 []string | |
| 171 | + | jobCtx context.Context | |
| 161 | 172 | ||
| 162 | 173 | // Results of the commands (see issues.go, matchers.go, results.go). | |
| 163 | 174 | matchers *matcherRoot | |
| @@ -210,6 +221,16 @@ func (j *job) run() Result { | |||
| 210 | 221 | // ---- Set up job (JobExtension.InitializeJob) ---- | |
| 211 | 222 | rep := j.cfg.Reporter | |
| 212 | 223 | rep.StepStarted(1, SetUpJobName, j.now()) | |
| 224 | + | var cancelCtx context.CancelFunc | |
| 225 | + | j.jobCtx, cancelCtx = context.WithCancel(context.Background()) | |
| 226 | + | defer cancelCtx() | |
| 227 | + | go func() { | |
| 228 | + | select { | |
| 229 | + | case <-j.cancel.Done(): | |
| 230 | + | cancelCtx() | |
| 231 | + | case <-j.jobCtx.Done(): | |
| 232 | + | } | |
| 233 | + | }() | |
| 213 | 234 | steps, err := j.setUp() | |
| 214 | 235 | if err != nil { | |
| 215 | 236 | j.errorf(1, "%s", err.Error()) | |
| @@ -220,13 +241,29 @@ func (j *job) run() Result { | |||
| 220 | 241 | rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now()) | |
| 221 | 242 | ||
| 222 | 243 | // ---- the steps (StepsRunner) ---- | |
| 223 | - | for i, st := range steps { | |
| 224 | - | j.runStep(i+2, st) | |
| 244 | + | // The queue is the pre-job steps then the steps of the job; the post steps | |
| 245 | + | // registered while they run follow, last registered first | |
| 246 | + | // (StepsRunner.cs:62-75). | |
| 247 | + | num := 2 | |
| 248 | + | for _, q := range steps { | |
| 249 | + | j.runStep(num, q) | |
| 250 | + | num++ | |
| 251 | + | } | |
| 252 | + | for { | |
| 253 | + | j.mu.Lock() | |
| 254 | + | if len(j.postStack) == 0 { | |
| 255 | + | j.mu.Unlock() | |
| 256 | + | break | |
| 257 | + | } | |
| 258 | + | q := j.postStack[len(j.postStack)-1] | |
| 259 | + | j.postStack = j.postStack[:len(j.postStack)-1] | |
| 260 | + | j.mu.Unlock() | |
| 261 | + | j.runStep(num, q) | |
| 262 | + | num++ | |
| 225 | 263 | } | |
| 226 | 264 | ||
| 227 | 265 | // ---- Complete job (JobExtension.FinalizeJob) ---- | |
| 228 | - | last := len(steps) + 2 | |
| 229 | - | outputs := j.completeJob(last) | |
| 266 | + | outputs := j.completeJob(num) | |
| 230 | 267 | ||
| 231 | 268 | return j.finish(outputs) | |
| 232 | 269 | } | |
| @@ -345,7 +382,7 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) { | |||
| 345 | 382 | // setUp parses the workflow and prepares the job: JobExtension.InitializeJob | |
| 346 | 383 | // (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its | |
| 347 | 384 | // log lines belong to step 1. | |
| 348 | - | func (j *job) setUp() ([]*workflow.Step, error) { | |
| 385 | + | func (j *job) setUp() ([]qstep, error) { | |
| 349 | 386 | cfg := j.cfg | |
| 350 | 387 | j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion)) | |
| 351 | 388 | // Only a self-hosted runner prints its name and machine (JobExtension.cs: | |
| @@ -472,6 +509,10 @@ func (j *job) setUp() ([]*workflow.Step, error) { | |||
| 472 | 509 | } | |
| 473 | 510 | ||
| 474 | 511 | j.out(1, "Prepare all required actions") | |
| 512 | + | queue, err := j.prepareJob() | |
| 513 | + | if err != nil { | |
| 514 | + | return nil, err | |
| 515 | + | } | |
| 475 | 516 | // JobExtension.cs:418-422: the display name of the job, which the server | |
| 476 | 517 | // computes there (message.JobDisplayName). ermite's Job message has no such | |
| 477 | 518 | // field, so the executor evaluates it (see jobDisplayName). | |
| @@ -488,7 +529,7 @@ func (j *job) setUp() ([]*workflow.Step, error) { | |||
| 488 | 529 | for _, p := range snapshotProcesses() { | |
| 489 | 530 | j.before[p.key()] = true | |
| 490 | 531 | } | |
| 491 | - | return jb.Steps, nil | |
| 532 | + | return queue, nil | |
| 492 | 533 | } | |
| 493 | 534 | ||
| 494 | 535 | // own gives a file or directory created by the executor to the user the steps | |
| @@ -590,6 +631,15 @@ func (j *job) completeJob(num int) map[string]string { | |||
| 590 | 631 | j.out(num, "Cleaning up orphan processes") | |
| 591 | 632 | killOrphans(j.trackID, j.before, func(l string) { j.out(num, l) }) | |
| 592 | 633 | ||
| 634 | + | // The actions that asked for node20 and got node24 (JobExtension.cs:1043-1049). | |
| 635 | + | j.mu.Lock() | |
| 636 | + | up := slices.SortedFunc(slices.Values(j.upgradedToNode24), func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }) | |
| 637 | + | j.mu.Unlock() | |
| 638 | + | if len(up) > 0 { | |
| 639 | + | j.tagged(num, "##[warning]", "Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: "+ | |
| 640 | + | strings.Join(up, ", ")+". For more information see: "+node20DeprecationURL) | |
| 641 | + | } | |
| 642 | + | ||
| 593 | 643 | rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now()) | |
| 594 | 644 | return outputs | |
| 595 | 645 | } | |
M internal/executor/job_test.go +12 -17
| @@ -194,23 +194,6 @@ jobs: | |||
| 194 | 194 | ||
| 195 | 195 | func TestNotSupportedYet(t *testing.T) { | |
| 196 | 196 | needTools(t, "bash") | |
| 197 | - | rec, res := runLocal(t, wfHead+` steps: | |
| 198 | - | - uses: actions/checkout@v4 | |
| 199 | - | - name: after | |
| 200 | - | if: always() | |
| 201 | - | run: echo still-runs | |
| 202 | - | - run: echo skipped | |
| 203 | - | `, "j", nil) | |
| 204 | - | if res.Conclusion != ConclusionFailure { | |
| 205 | - | t.Fatalf("%+v", res) | |
| 206 | - | } | |
| 207 | - | u := rec.step(t, "Run actions/checkout@v4") | |
| 208 | - | if u.Conclusion != ConclusionFailure || !strings.Contains(logOf(u), "##[error]not supported yet: uses (actions/checkout@v4)") { | |
| 209 | - | t.Fatalf("%+v", u) | |
| 210 | - | } | |
| 211 | - | if rec.step(t, "after").Conclusion != ConclusionSuccess { | |
| 212 | - | t.Error("always() steps still run after a failure") | |
| 213 | - | } | |
| 214 | 197 | for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} { | |
| 215 | 198 | rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil) | |
| 216 | 199 | if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") { | |
| @@ -219,6 +202,18 @@ func TestNotSupportedYet(t *testing.T) { | |||
| 219 | 202 | } | |
| 220 | 203 | } | |
| 221 | 204 | ||
| 205 | + | // Without a resolver (a host without the feature) an action fails "Set up job" | |
| 206 | + | // before any step, with the runner's message. | |
| 207 | + | func TestActionWithoutResolverFailsSetUp(t *testing.T) { | |
| 208 | + | rec, res := runLocal(t, wfHead+" steps:\n - uses: actions/checkout@v4\n - run: echo NO\n", "j", nil) | |
| 209 | + | if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 { | |
| 210 | + | t.Fatalf("%+v %v", res, rec.names()) | |
| 211 | + | } | |
| 212 | + | if got := logOf(rec.steps[0]); !strings.Contains(got, "##[error]Failed to resolve action download info.") { | |
| 213 | + | t.Fatalf("%s", got) | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 222 | 217 | func TestInvalidWorkflowAndUnknownJob(t *testing.T) { | |
| 223 | 218 | rec, res := runLocal(t, "on: push\njobs: [", "j", nil) | |
| 224 | 219 | if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 || !rec.done { | |
M internal/executor/step.go +56 -172
| @@ -5,10 +5,10 @@ package executor | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | - | "fmt" | |
| 9 | 8 | "strings" | |
| 10 | - | "sync" | |
| 9 | + | "time" | |
| 11 | 10 | ||
| 11 | + | "bnema.dev/ermite-actions/action" | |
| 12 | 12 | "bnema.dev/ermite-actions/expr" | |
| 13 | 13 | "bnema.dev/ermite-actions/template" | |
| 14 | 14 | "bnema.dev/ermite-actions/workflow" | |
| @@ -17,21 +17,64 @@ import ( | |||
| 17 | 17 | // ---------------------------------------------------------------- steps | |
| 18 | 18 | ||
| 19 | 19 | // stepRun is the state of one step being run; it implements Sink and | |
| 20 | - | // commandHost. | |
| 20 | + | // commandHost. An embedded step (inside a composite action) is a stepRun too, | |
| 21 | + | // with a parent and the number of the outer step: its log lines belong to it. | |
| 21 | 22 | type stepRun struct { | |
| 22 | 23 | j *job | |
| 23 | 24 | num int | |
| 24 | 25 | st *workflow.Step | |
| 26 | + | n *node | |
| 27 | + | stage stage | |
| 28 | + | parent *stepRun | |
| 25 | 29 | env *ordered // the step's environment: job env + step env | |
| 30 | + | overrides *ordered // the step env of this step and of its composite parents | |
| 26 | 31 | cmdResult *stepResult | |
| 27 | 32 | outcome *stepResult | |
| 28 | 33 | cmds *commandManager | |
| 34 | + | // result is ExecutionContext.Result of a step that runs embedded steps: it | |
| 35 | + | // gives action_status to them. | |
| 36 | + | result *stepResult | |
| 37 | + | // fc is the file commands of the step: the job's for a step of the job, a | |
| 38 | + | // fork for an embedded one. | |
| 39 | + | fc *FileCommands | |
| 40 | + | // actEnv, when set, is the environment of the process of an action step | |
| 41 | + | // (env context, STATE_ and INPUT_); rawEnvList replaces the whole | |
| 42 | + | // environment of the process (docker, whose -e options read it). | |
| 43 | + | actEnv *ordered | |
| 44 | + | rawEnvList []string | |
| 45 | + | // def is the action the step runs. | |
| 46 | + | def *actionDef | |
| 47 | + | nodeVersion string | |
| 29 | 48 | // scope is what the step shares with the step that contains it, see | |
| 30 | - | // stepScope. name is the display name; timedOut is set when the step | |
| 49 | + | // stepScope. name is the display name of the step in the log; timedOut is set when the step | |
| 31 | 50 | // reached its timeout-minutes. | |
| 32 | 51 | scope stepScope | |
| 33 | 52 | name string | |
| 34 | 53 | timedOut timedOutFlag | |
| 54 | + | // deadline is when the timeout-minutes of the step, or of the step around it, | |
| 55 | + | // ends: zero for none. An inner step of a composite action inherits what is | |
| 56 | + | // left of it (ExecutionContext.CreateEmbeddedChild, :498). | |
| 57 | + | deadline time.Time | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // depth is the number of composite actions around the step. | |
| 61 | + | func (s *stepRun) depth() int { | |
| 62 | + | d := 0 | |
| 63 | + | for p := s.parent; p != nil; p = p.parent { | |
| 64 | + | d++ | |
| 65 | + | } | |
| 66 | + | return d | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // procEnv is the environment of the process the step starts. | |
| 70 | + | func (s *stepRun) procEnv() []string { | |
| 71 | + | if s.rawEnvList != nil { | |
| 72 | + | return s.rawEnvList | |
| 73 | + | } | |
| 74 | + | if s.actEnv != nil { | |
| 75 | + | return s.j.processEnv(s.actEnv) | |
| 76 | + | } | |
| 77 | + | return s.j.processEnv(s.env) | |
| 35 | 78 | } | |
| 36 | 79 | ||
| 37 | 80 | func (s *stepRun) Out(text string) { s.j.out(s.num, text) } | |
| @@ -86,6 +129,15 @@ func (j *job) displayName(st *workflow.Step, env *ordered) string { | |||
| 86 | 129 | case st.Run != nil: | |
| 87 | 130 | tok, prefix = st.Run, "Run " | |
| 88 | 131 | case st.Uses != nil: | |
| 132 | + | // The name is built from the parsed reference (ActionRunner.cs:305-333): | |
| 133 | + | // the image of a docker:// step without its scheme, "Name[/Path]@Ref" for | |
| 134 | + | // a repository, the path for a local action. | |
| 135 | + | if u, err := action.ParseUses(st.Uses.Value); err == nil { | |
| 136 | + | if u.Kind == action.UsesDocker { | |
| 137 | + | return formatStepName("Run ", u.Image) | |
| 138 | + | } | |
| 139 | + | return formatStepName("Run ", refString(u)) | |
| 140 | + | } | |
| 89 | 141 | tok, prefix = st.Uses, "Run " | |
| 90 | 142 | default: | |
| 91 | 143 | return "" | |
| @@ -206,165 +258,6 @@ func formatStepName(prefix, name string) string { | |||
| 206 | 258 | return prefix + firstLine(name) | |
| 207 | 259 | } | |
| 208 | 260 | ||
| 209 | - | func (j *job) statusFuncs() workflow.StatusFuncs { | |
| 210 | - | st := j.state.Status() | |
| 211 | - | return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"} | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | // markCancelled records the cancellation of the job: job result Canceled and | |
| 215 | - | // job.status cancelled (StepsRunner.cs:152-155 and :190-196). | |
| 216 | - | func (j *job) markCancelledLocked() { | |
| 217 | - | if j.cancelled { | |
| 218 | - | return | |
| 219 | - | } | |
| 220 | - | j.cancelled = true | |
| 221 | - | c := resCanceled | |
| 222 | - | j.result = &c | |
| 223 | - | j.state.SetStatus("cancelled") | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | func (j *job) runStep(num int, st *workflow.Step) { | |
| 227 | - | rep := j.cfg.Reporter | |
| 228 | - | s := &stepRun{j: j, num: num, st: st} | |
| 229 | - | s.cmds = newCommandManager(s, j.cfg.Debug) | |
| 230 | - | ||
| 231 | - | // Environment of the step (StepsRunner.cs:108-139): job env, then the step's | |
| 232 | - | // own, evaluated against the job env. | |
| 233 | - | j.mu.Lock() | |
| 234 | - | // github.action is the id of the step, set before the step's env is | |
| 235 | - | // evaluated (StepsRunner.cs:118), so that the env and the if of a step see | |
| 236 | - | // its own id (the dump step of the recordings reads __run_6, not __run_5). | |
| 237 | - | j.state.SetGitHub("action", st.ID) | |
| 238 | - | jobEnv := j.state.JobEnv() | |
| 239 | - | j.mu.Unlock() | |
| 240 | - | s.env = jobEnv.Clone() | |
| 241 | - | pairs, envErr := j.evalMap(st.Env, jobEnv) | |
| 242 | - | if envErr == nil { | |
| 243 | - | for _, p := range pairs { | |
| 244 | - | s.env.Set(p.Key, p.Value) | |
| 245 | - | } | |
| 246 | - | } | |
| 247 | - | name := j.displayName(st, s.env) | |
| 248 | - | s.name = name | |
| 249 | - | rep.StepStarted(num, name, j.now()) | |
| 250 | - | ||
| 251 | - | complete := func(r stepResult) { | |
| 252 | - | res := r | |
| 253 | - | j.mu.Lock() | |
| 254 | - | // Update the steps context (ExecutionContext.UpdateGlobalStepsContext). | |
| 255 | - | outcome := res | |
| 256 | - | if s.outcome != nil { | |
| 257 | - | outcome = *s.outcome | |
| 258 | - | } | |
| 259 | - | j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion())) | |
| 260 | - | j.recordOutcome(num, outcome.conclusion()) | |
| 261 | - | if res == resFailed { | |
| 262 | - | m := merge(j.result, resFailed) | |
| 263 | - | j.result = &m | |
| 264 | - | j.state.SetStatus(statusName(m)) | |
| 265 | - | } | |
| 266 | - | j.mu.Unlock() | |
| 267 | - | rep.StepCompleted(num, name, res.conclusion(), j.now()) | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | if envErr != nil { | |
| 271 | - | j.errorf(num, "%s", templateNotValid(envErr, true)) | |
| 272 | - | complete(resFailed) | |
| 273 | - | return | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | // Cancellation seen before the step starts (StepsRunner.cs:188-197). | |
| 277 | - | cancelledAtStart := false | |
| 278 | - | select { | |
| 279 | - | case <-j.cancel.Done(): | |
| 280 | - | cancelledAtStart = true | |
| 281 | - | j.mu.Lock() | |
| 282 | - | j.markCancelledLocked() | |
| 283 | - | j.mu.Unlock() | |
| 284 | - | default: | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not | |
| 288 | - | // evaluated and the step is skipped. | |
| 289 | - | cond := func() (bool, error) { | |
| 290 | - | j.mu.Lock() | |
| 291 | - | defer j.mu.Unlock() | |
| 292 | - | return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs()) | |
| 293 | - | } | |
| 294 | - | if cancelledAtStart && j.cancel.Reason() == CancelShutdown { | |
| 295 | - | complete(resSkipped) | |
| 296 | - | return | |
| 297 | - | } | |
| 298 | - | ok, err := cond() | |
| 299 | - | switch { | |
| 300 | - | case err != nil: | |
| 301 | - | j.errorf(num, "%s", templateNotValid(err, false)) | |
| 302 | - | complete(resFailed) | |
| 303 | - | return | |
| 304 | - | case !ok: | |
| 305 | - | complete(resSkipped) | |
| 306 | - | return | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes | |
| 310 | - | // cancelled and the step is stopped unless its condition still holds | |
| 311 | - | // (always()). | |
| 312 | - | stepCancel := make(chan struct{}) | |
| 313 | - | var cancelOnce sync.Once | |
| 314 | - | closeCancel := func() { cancelOnce.Do(func() { close(stepCancel) }) } | |
| 315 | - | stopWatch := make(chan struct{}) | |
| 316 | - | var watch sync.WaitGroup | |
| 317 | - | if !cancelledAtStart { | |
| 318 | - | watch.Add(1) | |
| 319 | - | go func() { | |
| 320 | - | defer watch.Done() | |
| 321 | - | select { | |
| 322 | - | case <-j.cancel.Done(): | |
| 323 | - | case <-stopWatch: | |
| 324 | - | return | |
| 325 | - | } | |
| 326 | - | j.mu.Lock() | |
| 327 | - | j.markCancelledLocked() | |
| 328 | - | j.mu.Unlock() | |
| 329 | - | keep := false | |
| 330 | - | if j.cancel.Reason() != CancelShutdown { | |
| 331 | - | keep, _ = cond() | |
| 332 | - | } | |
| 333 | - | if !keep { | |
| 334 | - | closeCancel() | |
| 335 | - | } | |
| 336 | - | }() | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | stopTimeout, minutes := j.armStepTimeout(s, closeCancel) | |
| 340 | - | defer stopTimeout() | |
| 341 | - | ||
| 342 | - | r := j.execute(s, stepCancel) | |
| 343 | - | close(stopWatch) | |
| 344 | - | watch.Wait() | |
| 345 | - | r = j.stepTimedOut(s, r, minutes) | |
| 346 | - | ||
| 347 | - | // Merge the result of the commands (StepsRunner.cs:347-351). | |
| 348 | - | if s.cmdResult != nil { | |
| 349 | - | m := merge(&r, *s.cmdResult) | |
| 350 | - | r = m | |
| 351 | - | } | |
| 352 | - | // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422). | |
| 353 | - | if r == resFailed && st.ContinueOnError != nil { | |
| 354 | - | coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env)) | |
| 355 | - | if cerr != nil { | |
| 356 | - | j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.") | |
| 357 | - | j.errorf(num, "%s", cerr.Error()) | |
| 358 | - | } | |
| 359 | - | if coe && cerr == nil { | |
| 360 | - | o := resFailed | |
| 361 | - | s.outcome = &o | |
| 362 | - | r = resSucceeded | |
| 363 | - | } | |
| 364 | - | } | |
| 365 | - | complete(r) | |
| 366 | - | } | |
| 367 | - | ||
| 368 | 261 | // statusName is the job.status for a result (TaskResult.ToActionResult). | |
| 369 | 262 | func statusName(r stepResult) string { | |
| 370 | 263 | switch r { | |
| @@ -375,12 +268,3 @@ func statusName(r stepResult) string { | |||
| 375 | 268 | } | |
| 376 | 269 | return "success" | |
| 377 | 270 | } | |
| 378 | - | ||
| 379 | - | // notSupported is the failure of a step that uses a feature of a later phase. | |
| 380 | - | func notSupported(st *workflow.Step) string { | |
| 381 | - | switch { | |
| 382 | - | case st.Uses != nil: | |
| 383 | - | return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value) | |
| 384 | - | } | |
| 385 | - | return "" | |
| 386 | - | } | |
A internal/executor/step_run.go +354 -0
| @@ -0,0 +1,354 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-actions/action" | |
| 11 | + | "bnema.dev/ermite-actions/expr" | |
| 12 | + | "bnema.dev/ermite-actions/template" | |
| 13 | + | "bnema.dev/ermite-actions/workflow" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // qstep is a step of the job's queue (StepsRunner's JobSteps): a main step, the | |
| 17 | + | // pre or post stage of an action, or a setup step (pull or build an image). | |
| 18 | + | type qstep struct { | |
| 19 | + | n *node | |
| 20 | + | stage stage | |
| 21 | + | name string | |
| 22 | + | // cond, for a pre or post stage, replaces the step's own condition (the | |
| 23 | + | // action's pre-if or post-if); setup steps use "success()". | |
| 24 | + | cond string | |
| 25 | + | // setup, when set, is the whole step: it runs instead of the node. | |
| 26 | + | setup func(s *stepRun, cancel <-chan struct{}) stepResult | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (j *job) statusFuncs() workflow.StatusFuncs { | |
| 30 | + | st := j.state.Status() | |
| 31 | + | return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"} | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // actionStatus is github.action_status of the steps of a composite action: the | |
| 35 | + | // result of the action so far (CompositeActionHandler.cs:246), the job's | |
| 36 | + | // cancellation included (:340). j.mu is held. | |
| 37 | + | func (s *stepRun) actionStatus() string { | |
| 38 | + | if s.j.cancelled { | |
| 39 | + | return "cancelled" | |
| 40 | + | } | |
| 41 | + | if s.result != nil { | |
| 42 | + | return statusName(*s.result) | |
| 43 | + | } | |
| 44 | + | return "success" | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // statusFuncs are the status functions of the step: success() and failure() | |
| 48 | + | // of an embedded main step read action_status, the others the job's status | |
| 49 | + | // (SuccessFunction.cs:28-45). j.mu is held. | |
| 50 | + | func (s *stepRun) statusFuncs() workflow.StatusFuncs { | |
| 51 | + | if s.parent != nil && s.stage == stageMain { | |
| 52 | + | a := s.parent.actionStatus() | |
| 53 | + | return workflow.StatusFuncs{Success: a == "success", Failure: a == "failure", Cancelled: s.j.state.Status() == "cancelled"} | |
| 54 | + | } | |
| 55 | + | return s.j.statusFuncs() | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // markCancelled records the cancellation of the job: job result Canceled and | |
| 59 | + | // job.status cancelled (StepsRunner.cs:152-155 and :190-196). | |
| 60 | + | func (j *job) markCancelledLocked() { | |
| 61 | + | if j.cancelled { | |
| 62 | + | return | |
| 63 | + | } | |
| 64 | + | j.cancelled = true | |
| 65 | + | c := resCanceled | |
| 66 | + | j.result = &c | |
| 67 | + | j.state.SetStatus("cancelled") | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // evalCondString evaluates the pre-if or post-if of an action (an expression | |
| 71 | + | // without ${{ }}, evaluated as written, EvaluateStepIf on a bare condition). | |
| 72 | + | func (j *job) evalCondString(cond string, env *ordered, st workflow.StatusFuncs) (bool, error) { | |
| 73 | + | sch, err := action.Schema() | |
| 74 | + | if err != nil { | |
| 75 | + | return false, err | |
| 76 | + | } | |
| 77 | + | tok := template.NewExpression(template.Origin{}, cond, sch.Info("step-if").WithContexts("vars", "needs")) | |
| 78 | + | te := j.evalEnv(env) | |
| 79 | + | funcs := map[string]expr.Func{} | |
| 80 | + | for k, f := range te.Funcs { | |
| 81 | + | funcs[k] = f | |
| 82 | + | } | |
| 83 | + | for k, f := range st.Funcs() { | |
| 84 | + | funcs[k] = f | |
| 85 | + | } | |
| 86 | + | te.Funcs = funcs | |
| 87 | + | v, err := template.EvaluateExpression(context.Background(), tok, te) | |
| 88 | + | if err != nil { | |
| 89 | + | return false, err | |
| 90 | + | } | |
| 91 | + | return expr.Truthy(v), nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // stepEnvironment builds the environment of a step (StepsRunner.cs:108-139 and | |
| 95 | + | // CompositeActionHandler.cs:233-262): the job's, plus for an embedded step the | |
| 96 | + | // env of the steps around it, then its own env, evaluated against that. | |
| 97 | + | func (j *job) stepEnvironment(s *stepRun) (*ordered, error) { | |
| 98 | + | j.mu.Lock() | |
| 99 | + | env := j.state.JobEnv() | |
| 100 | + | j.mu.Unlock() | |
| 101 | + | s.overrides = newOrdered() | |
| 102 | + | if p := s.parent; p != nil { | |
| 103 | + | for _, k := range p.overrides.Keys() { | |
| 104 | + | v, _ := p.env.Get(k) | |
| 105 | + | env.Set(k, v) | |
| 106 | + | s.overrides.Set(k, v) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | pairs, err := j.evalStepEnv(s.st, env) | |
| 110 | + | if err != nil { | |
| 111 | + | return env, err | |
| 112 | + | } | |
| 113 | + | for _, p := range pairs { | |
| 114 | + | env.Set(p.Key, p.Value) | |
| 115 | + | s.overrides.Set(p.Key, p.Value) | |
| 116 | + | } | |
| 117 | + | return env, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // evalStepEnv evaluates the env of a step: a mapping, or the expression that | |
| 121 | + | // stands for one (a step of a composite action may write env: ${{ ... }}). | |
| 122 | + | func (j *job) evalStepEnv(st *workflow.Step, env *ordered) ([]Pair, error) { | |
| 123 | + | if st.Env != nil || st.EnvExpression == nil { | |
| 124 | + | return j.evalMap(st.Env, env) | |
| 125 | + | } | |
| 126 | + | v, err := template.Evaluate(context.Background(), st.EnvExpression, j.evalEnv(env)) | |
| 127 | + | if err != nil { | |
| 128 | + | return nil, err | |
| 129 | + | } | |
| 130 | + | return pairsOf(v) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func pairsOf(v expr.Value) ([]Pair, error) { | |
| 134 | + | d, ok := v.(*expr.Dictionary) | |
| 135 | + | if !ok { | |
| 136 | + | return nil, nil | |
| 137 | + | } | |
| 138 | + | var out []Pair | |
| 139 | + | for _, p := range d.Pairs() { | |
| 140 | + | if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary { | |
| 141 | + | return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k) | |
| 142 | + | } | |
| 143 | + | out = append(out, Pair{p.Key, expr.CoerceString(p.Value)}) | |
| 144 | + | } | |
| 145 | + | return out, nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // runStep runs one step of the queue as a step of the job (its own number, its | |
| 149 | + | // own record in the log). | |
| 150 | + | func (j *job) runStep(num int, q qstep) { | |
| 151 | + | rep := j.cfg.Reporter | |
| 152 | + | s := &stepRun{j: j, num: num, stage: q.stage} | |
| 153 | + | if q.n != nil { | |
| 154 | + | s.st, s.n = q.n.st, q.n | |
| 155 | + | // The state saved by the action is the one of its node, whatever the stage. | |
| 156 | + | s.scope.stateKey = q.n.stateKey | |
| 157 | + | } | |
| 158 | + | s.cmds = newCommandManager(s, j.cfg.Debug) | |
| 159 | + | name := q.name | |
| 160 | + | if q.setup != nil { | |
| 161 | + | s.name = name | |
| 162 | + | rep.StepStarted(num, name, j.now()) | |
| 163 | + | r := j.runGuarded(s, q, true) | |
| 164 | + | rep.StepCompleted(num, name, r.conclusion(), j.now()) | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | if q.n == nil { | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | // github.action is the id of the step, set before the step's env is | |
| 171 | + | // evaluated (StepsRunner.cs:118), so that the env and the if of a step see | |
| 172 | + | // its own id (the dump step of the recordings reads __run_6, not __run_5). | |
| 173 | + | j.mu.Lock() | |
| 174 | + | j.state.SetGitHub("action", q.n.st.ID) | |
| 175 | + | j.mu.Unlock() | |
| 176 | + | env, envErr := j.stepEnvironment(s) | |
| 177 | + | s.env = env | |
| 178 | + | if name == "" { | |
| 179 | + | name = j.displayName(q.n.st, s.env) | |
| 180 | + | if q.stage == stagePre { | |
| 181 | + | name = "Pre " + name | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | s.name = name | |
| 185 | + | rep.StepStarted(num, name, j.now()) | |
| 186 | + | r := j.runGuarded(s, q, envErr == nil) | |
| 187 | + | if envErr != nil { | |
| 188 | + | j.errorf(num, "%s", templateNotValid(envErr, true)) | |
| 189 | + | r = resFailed | |
| 190 | + | j.finishStep(s, r) | |
| 191 | + | } | |
| 192 | + | rep.StepCompleted(num, name, r.conclusion(), j.now()) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // finishStep records the result of the step in the contexts | |
| 196 | + | // (ExecutionContext.UpdateGlobalStepsContext) and, for a step of the job, in | |
| 197 | + | // the result of the job. | |
| 198 | + | func (j *job) finishStep(s *stepRun, res stepResult) { | |
| 199 | + | j.mu.Lock() | |
| 200 | + | defer j.mu.Unlock() | |
| 201 | + | st := s.st | |
| 202 | + | outcome := res | |
| 203 | + | if s.outcome != nil { | |
| 204 | + | outcome = *s.outcome | |
| 205 | + | } | |
| 206 | + | // Pre and post stages of a step of the job have no context name, and | |
| 207 | + | // generated ids are hidden: only the main stage and embedded steps count. | |
| 208 | + | if s.n != nil && (s.parent != nil || s.stage == stageMain) && st != nil { | |
| 209 | + | j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion())) | |
| 210 | + | } | |
| 211 | + | if s.parent == nil { | |
| 212 | + | j.recordOutcome(s.num, outcome.conclusion()) | |
| 213 | + | } | |
| 214 | + | if s.parent != nil { | |
| 215 | + | if res == resFailed || res == resCanceled { | |
| 216 | + | m := merge(s.parent.result, res) | |
| 217 | + | s.parent.result = &m | |
| 218 | + | } | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | if res == resFailed { | |
| 222 | + | m := merge(j.result, resFailed) | |
| 223 | + | j.result = &m | |
| 224 | + | j.state.SetStatus(statusName(m)) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // runGuarded is the body of a step between its start and its end: the | |
| 229 | + | // condition, the run with its cancellation watch, the commands' result, | |
| 230 | + | // continue-on-error. ok is false when the env of the step did not evaluate (the | |
| 231 | + | // error is reported by the caller). | |
| 232 | + | func (j *job) runGuarded(s *stepRun, q qstep, ok bool) stepResult { | |
| 233 | + | num, st := s.num, s.st | |
| 234 | + | complete := func(r stepResult) stepResult { j.finishStep(s, r); return r } | |
| 235 | + | if !ok { | |
| 236 | + | return resFailed | |
| 237 | + | } | |
| 238 | + | // Cancellation seen before the step starts (StepsRunner.cs:188-197). | |
| 239 | + | cancelledAtStart := false | |
| 240 | + | select { | |
| 241 | + | case <-j.cancel.Done(): | |
| 242 | + | cancelledAtStart = true | |
| 243 | + | j.mu.Lock() | |
| 244 | + | j.markCancelledLocked() | |
| 245 | + | j.mu.Unlock() | |
| 246 | + | default: | |
| 247 | + | } | |
| 248 | + | cond := func() (bool, error) { | |
| 249 | + | j.mu.Lock() | |
| 250 | + | defer j.mu.Unlock() | |
| 251 | + | switch { | |
| 252 | + | case q.setup != nil: | |
| 253 | + | return j.evalCondString("success()", s.env, j.statusFuncs()) | |
| 254 | + | case q.cond != "": | |
| 255 | + | return j.evalCondString(q.cond, s.env, s.statusFuncs()) | |
| 256 | + | } | |
| 257 | + | return st.EvaluateIf(context.Background(), j.evalEnv(s.env), s.statusFuncs()) | |
| 258 | + | } | |
| 259 | + | // On runner shutdown conditions are not evaluated and the step is skipped. | |
| 260 | + | if cancelledAtStart && j.cancel.Reason() == CancelShutdown { | |
| 261 | + | return complete(resSkipped) | |
| 262 | + | } | |
| 263 | + | run, err := cond() | |
| 264 | + | switch { | |
| 265 | + | case err != nil: | |
| 266 | + | j.errorf(num, "%s", templateNotValid(err, false)) | |
| 267 | + | return complete(resFailed) | |
| 268 | + | case !run: | |
| 269 | + | return complete(resSkipped) | |
| 270 | + | } | |
| 271 | + | // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes | |
| 272 | + | // cancelled and the step is stopped unless its condition still holds | |
| 273 | + | // (always()). | |
| 274 | + | stepCancel := make(chan struct{}) | |
| 275 | + | var cancelOnce sync.Once | |
| 276 | + | closeCancel := func() { cancelOnce.Do(func() { close(stepCancel) }) } | |
| 277 | + | stopWatch := make(chan struct{}) | |
| 278 | + | var watch sync.WaitGroup | |
| 279 | + | if !cancelledAtStart { | |
| 280 | + | watch.Add(1) | |
| 281 | + | go func() { | |
| 282 | + | defer watch.Done() | |
| 283 | + | select { | |
| 284 | + | case <-j.cancel.Done(): | |
| 285 | + | case <-stopWatch: | |
| 286 | + | return | |
| 287 | + | } | |
| 288 | + | j.mu.Lock() | |
| 289 | + | j.markCancelledLocked() | |
| 290 | + | j.mu.Unlock() | |
| 291 | + | keep := false | |
| 292 | + | if j.cancel.Reason() != CancelShutdown { | |
| 293 | + | keep, _ = cond() | |
| 294 | + | } | |
| 295 | + | if !keep { | |
| 296 | + | closeCancel() | |
| 297 | + | } | |
| 298 | + | }() | |
| 299 | + | } | |
| 300 | + | // timeout-minutes (StepsRunner.RunStepAsync): the timer cancels the body like | |
| 301 | + | // a job cancellation does, and does not cancel the job. An embedded step | |
| 302 | + | // has its own timer, the step around it has its own. | |
| 303 | + | var r stepResult | |
| 304 | + | stopTimeout, minutes := func() (func(), int) { return func() {}, 0 }() | |
| 305 | + | if q.setup == nil { | |
| 306 | + | stopTimeout, minutes = j.armStepTimeout(s, closeCancel) | |
| 307 | + | if minutes > 0 { | |
| 308 | + | s.deadline = j.now().Add(time.Duration(minutes)*time.Minute + j.cfg.StepTimeoutSkew) | |
| 309 | + | } | |
| 310 | + | if p := s.parent; p != nil && !p.deadline.IsZero() { | |
| 311 | + | // The inner step has the time that is left to the step around it, and | |
| 312 | + | // no timeout of its own (the schema refuses one). | |
| 313 | + | s.deadline = p.deadline | |
| 314 | + | t := time.AfterFunc(time.Until(p.deadline), func() { s.timedOut.Store(true); closeCancel() }) | |
| 315 | + | stopTimeout = func() { t.Stop() } | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | if q.setup != nil { | |
| 319 | + | r = q.setup(s, stepCancel) | |
| 320 | + | } else { | |
| 321 | + | r = j.execute(s, stepCancel) | |
| 322 | + | } | |
| 323 | + | stopTimeout() | |
| 324 | + | close(stopWatch) | |
| 325 | + | watch.Wait() | |
| 326 | + | if minutes > 0 { | |
| 327 | + | r = j.stepTimedOut(s, r, minutes) | |
| 328 | + | } | |
| 329 | + | if s.parent != nil && r == resCanceled && s.timedOut.Load() && !j.cancelRequested() { | |
| 330 | + | // CompositeActionHandler.RunStepAsync (:497-506): an inner step stopped by | |
| 331 | + | // the timeout it inherited fails with a message that names no step. | |
| 332 | + | j.errorf(num, "The action has timed out.") | |
| 333 | + | r = resFailed | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // Merge the result of the commands (StepsRunner.cs:347-351). | |
| 337 | + | if s.cmdResult != nil { | |
| 338 | + | r = merge(&r, *s.cmdResult) | |
| 339 | + | } | |
| 340 | + | // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422). | |
| 341 | + | if r == resFailed && st != nil && st.ContinueOnError != nil { | |
| 342 | + | coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env)) | |
| 343 | + | if cerr != nil { | |
| 344 | + | j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.") | |
| 345 | + | j.errorf(num, "%s", cerr.Error()) | |
| 346 | + | } | |
| 347 | + | if coe && cerr == nil { | |
| 348 | + | o := resFailed | |
| 349 | + | s.outcome = &o | |
| 350 | + | r = resSucceeded | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | return complete(r) | |
| 354 | + | } | |
M internal/guest/jobexec.go +7 -3
| @@ -212,6 +212,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 212 | 212 | } | |
| 213 | 213 | canceller := executor.NewCanceller() | |
| 214 | 214 | rep := &frameReporter{conn: conn, failed: make(chan struct{})} | |
| 215 | + | res := newResolver(conn) | |
| 215 | 216 | ||
| 216 | 217 | // Frames from the host while the job runs: a Cancel stops the job; a lost | |
| 217 | 218 | // host does too, like a runner shutdown. The reader ends with the stream: the | |
| @@ -229,6 +230,9 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 229 | 230 | if c := f.GetCancel(); c != nil { | |
| 230 | 231 | canceller.Cancel(cancelKind(c.GetKind())) | |
| 231 | 232 | } | |
| 233 | + | if r := f.GetResolveResponse(); r != nil { | |
| 234 | + | res.deliver(r) | |
| 235 | + | } | |
| 232 | 236 | } | |
| 233 | 237 | }() | |
| 234 | 238 | // The executor's own context (SIGTERM forwarded by init: the sandbox is | |
| @@ -245,18 +249,18 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 245 | 249 | }() | |
| 246 | 250 | ||
| 247 | 251 | env := spec.GetRunnerEnvironment() | |
| 248 | - | res := executor.Run(canceller, executor.Config{ | |
| 252 | + | result := executor.Run(canceller, executor.Config{ | |
| 249 | 253 | WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(), | |
| 250 | 254 | ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(), | |
| 251 | 255 | TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond, | |
| 252 | 256 | Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env}, | |
| 253 | 257 | RunnerVersion: spec.GetRunnerVersion(), | |
| 254 | 258 | Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential, | |
| 255 | - | Debug: spec.GetDebug(), Reporter: rep, Log: zerowrap.FromCtx(ctx), | |
| 259 | + | Debug: spec.GetDebug(), Reporter: rep, Actions: executor.ActionsConfig{Resolver: res.resolve}, Log: zerowrap.FromCtx(ctx), | |
| 256 | 260 | SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(), | |
| 257 | 261 | TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, | |
| 258 | 262 | }) | |
| 259 | - | if res.Conclusion == executor.ConclusionSuccess { | |
| 263 | + | if result.Conclusion == executor.ConclusionSuccess { | |
| 260 | 264 | return 0 | |
| 261 | 265 | } | |
| 262 | 266 | return 1 | |
A internal/guest/resolver.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package guest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "sync" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-runner/internal/execstream" | |
| 11 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 12 | + | "bnema.dev/ermite-runner/internal/executor" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // resolver sends ResolveRequest frames to the host and matches the | |
| 16 | + | // ResolveResponse frames that come back (the job token stays in the host). | |
| 17 | + | type resolver struct { | |
| 18 | + | conn *execstream.Conn | |
| 19 | + | mu sync.Mutex | |
| 20 | + | next int64 | |
| 21 | + | wait map[int64]chan *execpb.ResolveResponse | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func newResolver(conn *execstream.Conn) *resolver { | |
| 25 | + | return &resolver{conn: conn, wait: map[int64]chan *execpb.ResolveResponse{}} | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (r *resolver) deliver(resp *execpb.ResolveResponse) { | |
| 29 | + | r.mu.Lock() | |
| 30 | + | ch := r.wait[resp.Id] | |
| 31 | + | delete(r.wait, resp.Id) | |
| 32 | + | r.mu.Unlock() | |
| 33 | + | if ch != nil { | |
| 34 | + | ch <- resp | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // resolve is executor.Config.Resolver. | |
| 39 | + | func (r *resolver) resolve(ctx context.Context, uses []string) ([]executor.ResolvedAction, error) { | |
| 40 | + | ch := make(chan *execpb.ResolveResponse, 1) | |
| 41 | + | r.mu.Lock() | |
| 42 | + | r.next++ | |
| 43 | + | id := r.next | |
| 44 | + | r.wait[id] = ch | |
| 45 | + | r.mu.Unlock() | |
| 46 | + | forget := func() { r.mu.Lock(); delete(r.wait, id); r.mu.Unlock() } | |
| 47 | + | req := &execpb.ResolveRequest{Id: id, Uses: uses} | |
| 48 | + | if err := r.conn.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: req}}); err != nil { | |
| 49 | + | forget() | |
| 50 | + | return nil, err | |
| 51 | + | } | |
| 52 | + | select { | |
| 53 | + | case resp := <-ch: | |
| 54 | + | if resp.Error != "" { | |
| 55 | + | return nil, errors.New(resp.Error) | |
| 56 | + | } | |
| 57 | + | out := make([]executor.ResolvedAction, len(resp.Actions)) | |
| 58 | + | for i, a := range resp.Actions { | |
| 59 | + | out[i] = executor.ResolvedAction{ | |
| 60 | + | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha, | |
| 61 | + | TarballURL: a.TarballUrl, Token: a.Token, AuthOrigin: a.AuthOrigin, Error: a.Error, | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | return out, nil | |
| 65 | + | case <-ctx.Done(): | |
| 66 | + | forget() | |
| 67 | + | return nil, ctx.Err() | |
| 68 | + | } | |
| 69 | + | } | |
A internal/guest/resolver_test.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package guest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-runner/internal/execstream" | |
| 11 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // The resolver of the executor and the Resolve handler of the host meet on the | |
| 15 | + | // stream: requests are answered by id, in any order, and an error of the host | |
| 16 | + | // reaches the caller. | |
| 17 | + | func TestResolverRoundTrip(t *testing.T) { | |
| 18 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 19 | + | if err != nil { | |
| 20 | + | t.Skip(err) | |
| 21 | + | } | |
| 22 | + | defer func() { _ = ln.Close() }() | |
| 23 | + | guestSide, err := net.Dial("tcp", ln.Addr().String()) | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | hostSide, err := ln.Accept() | |
| 28 | + | if err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | defer func() { _ = hostSide.Close() }() | |
| 32 | + | defer func() { _ = guestSide.Close() }() | |
| 33 | + | ||
| 34 | + | gconn := execstream.NewConn(guestSide) | |
| 35 | + | res := newResolver(gconn) | |
| 36 | + | go func() { // the guest's frame reader | |
| 37 | + | for { | |
| 38 | + | f, err := gconn.Recv() | |
| 39 | + | if err != nil { | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | if r := f.GetResolveResponse(); r != nil { | |
| 43 | + | res.deliver(r) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | }() | |
| 47 | + | // The executor says Hello first, as it does at startup. | |
| 48 | + | if err := gconn.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | done := make(chan struct{}) | |
| 52 | + | go func() { | |
| 53 | + | defer close(done) | |
| 54 | + | _, _, _ = execstream.DriveJob(hostSide, &execpb.Run{}, nil, execstream.JobHandler{ | |
| 55 | + | Resolve: func(_ context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 56 | + | if len(r.Uses) == 1 && r.Uses[0] == "bad@v1" { | |
| 57 | + | return &execpb.ResolveResponse{Error: "no"} | |
| 58 | + | } | |
| 59 | + | out := &execpb.ResolveResponse{} | |
| 60 | + | for _, u := range r.Uses { | |
| 61 | + | out.Actions = append(out.Actions, &execpb.ResolvedAction{Ref: u, Token: "t-" + u, ResolvedSha: "sha"}) | |
| 62 | + | } | |
| 63 | + | return out | |
| 64 | + | }, | |
| 65 | + | }) | |
| 66 | + | }() | |
| 67 | + | // DriveJob sends Run after Hello; the guest ignores it. | |
| 68 | + | got, err := res.resolve(context.Background(), []string{"a/b@v1", "c/d@v2"}) | |
| 69 | + | if err != nil || len(got) != 2 || got[1].Ref != "c/d@v2" || got[1].Token != "t-c/d@v2" || got[0].ResolvedSHA != "sha" { | |
| 70 | + | t.Fatalf("%+v %v", got, err) | |
| 71 | + | } | |
| 72 | + | if _, err := res.resolve(context.Background(), []string{"bad@v1"}); err == nil || err.Error() != "no" { | |
| 73 | + | t.Fatalf("error not passed: %v", err) | |
| 74 | + | } | |
| 75 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 76 | + | cancel() | |
| 77 | + | _ = guestSide.Close() | |
| 78 | + | if _, err := res.resolve(ctx, []string{"x/y@z"}); err == nil { | |
| 79 | + | t.Error("a closed stream must fail") | |
| 80 | + | } | |
| 81 | + | <-done | |
| 82 | + | } | |
M internal/host/guestforge.go +6 -18
| @@ -8,8 +8,9 @@ import ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "io" | |
| 11 | - | "net/url" | |
| 12 | 11 | "strings" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite-runner/internal/urlorigin" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // serverURLKeys are the keys of the github context that actions use to reach | |
| @@ -110,23 +111,10 @@ func RewriteServerURLs(contexts, target string) (string, error) { | |||
| 110 | 111 | } | |
| 111 | 112 | ||
| 112 | 113 | // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and | |
| 113 | - | // without the default port, or "" for anything else (including URLs with | |
| 114 | - | // credentials, which are never rewritten). | |
| 115 | - | func originOf(raw string) string { | |
| 116 | - | u, err := url.Parse(raw) | |
| 117 | - | if err != nil || u.Host == "" || u.User != nil { | |
| 118 | - | return "" | |
| 119 | - | } | |
| 120 | - | scheme := strings.ToLower(u.Scheme) | |
| 121 | - | if scheme != "http" && scheme != "https" { | |
| 122 | - | return "" | |
| 123 | - | } | |
| 124 | - | host, port := strings.ToLower(u.Hostname()), u.Port() | |
| 125 | - | if port == "" || (scheme == "http" && port == "80") || (scheme == "https" && port == "443") { | |
| 126 | - | return scheme + "://" + host | |
| 127 | - | } | |
| 128 | - | return scheme + "://" + host + ":" + port | |
| 129 | - | } | |
| 114 | + | // without the default port, an IPv6 host in brackets, or "" for anything else | |
| 115 | + | // (including URLs with credentials, which are never rewritten). It is | |
| 116 | + | // urlorigin.Prefix, the form the executor compares by urlorigin.Canonical. | |
| 117 | + | func originOf(raw string) string { return urlorigin.Prefix(raw) } | |
| 130 | 118 | ||
| 131 | 119 | func marshalNoEscape(v any) (json.RawMessage, error) { | |
| 132 | 120 | var b bytes.Buffer | |
M internal/host/guestforge_test.go +19 -0
| @@ -112,6 +112,10 @@ func TestOriginOf(t *testing.T) { | |||
| 112 | 112 | "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test", | |
| 113 | 113 | "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test", | |
| 114 | 114 | "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "", | |
| 115 | + | // GO-012: an IPv6 host stays in brackets, and the host "::1:8443" is not the | |
| 116 | + | // host "::1" on port 8443. | |
| 117 | + | "https://[::1]:8443/x": "https://[::1]:8443", "https://[::1:8443]/x": "https://[::1:8443]", "https://[::1]:443/": "https://[::1]", | |
| 118 | + | "http://[2001:DB8::1]:80": "http://[2001:db8::1]", | |
| 115 | 119 | } { | |
| 116 | 120 | if got := originOf(in); got != want { | |
| 117 | 121 | t.Errorf("originOf(%q) = %q, want %q", in, got, want) | |
| @@ -146,3 +150,18 @@ func TestJobContextsAreRewrittenOnlyWithGuestForge(t *testing.T) { | |||
| 146 | 150 | } | |
| 147 | 151 | } | |
| 148 | 152 | } | |
| 153 | + | ||
| 154 | + | // GO-012: guest forge access on an IPv6 address. The URLs of the contexts are | |
| 155 | + | // rewritten to the runner's origin, the brackets and the port kept, and the | |
| 156 | + | // origin of the other host with the look-alike spelling is not touched. | |
| 157 | + | func TestRewriteServerURLsIPv6(t *testing.T) { | |
| 158 | + | in := `{"github":{"server_url":"https://[::1]:8443","api_url":"https://[::1]:8443/-/github/api","graphql_url":"https://[::1:8443]/x"}}` | |
| 159 | + | got, err := RewriteServerURLs(in, "http://[fd00::2]:8088") | |
| 160 | + | if err != nil { | |
| 161 | + | t.Fatal(err) | |
| 162 | + | } | |
| 163 | + | want := `{"github":{"server_url":"http://[fd00::2]:8088","api_url":"http://[fd00::2]:8088/-/github/api","graphql_url":"https://[::1:8443]/x"}}` | |
| 164 | + | if got != want { | |
| 165 | + | t.Errorf("got %s\nwant %s", got, want) | |
| 166 | + | } | |
| 167 | + | } | |
M internal/host/job.go +12 -0
| @@ -31,6 +31,7 @@ type jobClient interface { | |||
| 31 | 31 | Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error) | |
| 32 | 32 | WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error) | |
| 33 | 33 | CompleteJob(context.Context, *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) | |
| 34 | + | ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | var _ jobClient = ermiteconnect.RunnerServiceClient(nil) | |
| @@ -70,6 +71,11 @@ type jobRun struct { | |||
| 70 | 71 | // contexts is Job.Contexts as handed to the executor: with the server URLs | |
| 71 | 72 | // pointed at the runner's URL when guest forge access is on. | |
| 72 | 73 | contexts string | |
| 74 | + | // Origins that are ermite for the archive tokens (see resolveResponse), and | |
| 75 | + | // the budget of resolution requests of the job (see resolveActions). | |
| 76 | + | publicOrigin, serverOrigin, guestForge string | |
| 77 | + | resolveRounds, resolveRefs int | |
| 78 | + | resolved map[string]*execpb.ResolvedAction // by "uses", for the job | |
| 73 | 79 | // features is what the server accepts; annLists are the annotations the | |
| 74 | 80 | // executor sent for steps that have not completed yet (see results.go). | |
| 75 | 81 | features jobFeatures | |
| @@ -600,10 +606,15 @@ func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCL | |||
| 600 | 606 | // execute starts the sandbox, runs the job in it and returns how it ended. | |
| 601 | 607 | func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome { | |
| 602 | 608 | var err error | |
| 609 | + | j.setOrigins(j.job.Contexts, rt.Cfg.URL, rt.Cfg.GuestForgeOrigin) | |
| 603 | 610 | if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil { | |
| 604 | 611 | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | |
| 605 | 612 | return failure() | |
| 606 | 613 | } | |
| 614 | + | if j.contexts, err = SetGitHubToken(j.contexts, j.job.GithubToken); err != nil { | |
| 615 | + | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | |
| 616 | + | return failure() | |
| 617 | + | } | |
| 607 | 618 | sb, err := rt.Driver.New() | |
| 608 | 619 | if err != nil { | |
| 609 | 620 | j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err)) | |
| @@ -684,6 +695,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |||
| 684 | 695 | StepStarted: j.stepStarted, StepCompleted: j.stepCompleted, | |
| 685 | 696 | Log: j.logLine, | |
| 686 | 697 | JobResult: func(r *execpb.JobResult) { j.mu.Lock(); j.result = r; j.mu.Unlock() }, | |
| 698 | + | Resolve: j.resolveActions, | |
| 687 | 699 | StepAnnotations: j.stepAnnotations, StepSummary: j.stepSummary, | |
| 688 | 700 | }) | |
| 689 | 701 | return exit, err | |
A internal/host/resolve.go +342 -0
| @@ -0,0 +1,342 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package host | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "strings" | |
| 10 | + | "unicode/utf8" | |
| 11 | + | ||
| 12 | + | "connectrpc.com/connect" | |
| 13 | + | ||
| 14 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Limits of what the sandbox may ask the server through the host. The sandbox | |
| 20 | + | // runs untrusted code, so the host does not trust its requests. | |
| 21 | + | // | |
| 22 | + | // Resolutions are cached for the job, by "uses" value: an action that was | |
| 23 | + | // resolved once is never asked again (the same sha for the whole job is what the | |
| 24 | + | // server promises anyway), so a local composite action used 500 times costs one | |
| 25 | + | // resolution. The budget counts only what reaches the server: | |
| 26 | + | // | |
| 27 | + | // - a request from the sandbox has at most maxRequestRefs references (a | |
| 28 | + | // workflow has at most 1,000 steps), split into calls of maxResolveRefs, the | |
| 29 | + | // limit of ResolveActionsRequest; | |
| 30 | + | // - a job makes at most maxResolveRounds calls (up to 10 levels of repository | |
| 31 | + | // composites, then one call per local composite that brings new actions) and | |
| 32 | + | // asks for at most maxResolveRefsTotal distinct references. | |
| 33 | + | // | |
| 34 | + | // These are explicit limits of ermite, not of GitHub: a job that needs more | |
| 35 | + | // distinct actions than that fails "Set up job" with the message below. | |
| 36 | + | const ( | |
| 37 | + | maxRequestRefs = 1000 | |
| 38 | + | maxResolveRefs = 100 // per call to the server: ResolveActionsRequest.refs | |
| 39 | + | maxResolveRounds = 128 // calls per job | |
| 40 | + | maxResolveRefsTotal = 2048 // distinct references per job | |
| 41 | + | maxRefLen = 1024 // the whole "uses" value | |
| 42 | + | maxOwnerRepoLen = 100 // owner and repository names | |
| 43 | + | maxPathElemLen = 255 // an element of the path of the action: a file name | |
| 44 | + | maxRefNameLen = 255 | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | // checkUses validates a "uses" value of a request: owner/repo[/path]@ref. The | |
| 48 | + | // owner and the repository have the characters of repository names; the path of | |
| 49 | + | // the action is a path in the repository (any characters but control ones, with | |
| 50 | + | // no empty, "." or ".." element and no backslash, which the runner reads as a | |
| 51 | + | // separator, action.ParseUses); the ref has the characters git allows. The | |
| 52 | + | // server decides whether the action exists; this keeps garbage and oversized | |
| 53 | + | // strings from reaching it. | |
| 54 | + | func checkUses(u string) error { | |
| 55 | + | bad := func() error { return fmt.Errorf("%q is not a valid action reference", cut(u)) } | |
| 56 | + | if u == "" || len(u) > maxRefLen || !utf8.ValidString(u) { | |
| 57 | + | return bad() | |
| 58 | + | } | |
| 59 | + | name, ref, ok := strings.Cut(u, "@") | |
| 60 | + | if !ok || ref == "" || len(ref) > maxRefNameLen || strings.ContainsFunc(ref, badRefRune) { | |
| 61 | + | return bad() | |
| 62 | + | } | |
| 63 | + | segs := strings.Split(name, "/") | |
| 64 | + | if len(segs) < 2 { | |
| 65 | + | return bad() | |
| 66 | + | } | |
| 67 | + | for _, seg := range segs[:2] { | |
| 68 | + | if seg == "" || seg == "." || seg == ".." || len(seg) > maxOwnerRepoLen || strings.ContainsFunc(seg, badSegmentRune) { | |
| 69 | + | return bad() | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | for _, seg := range segs[2:] { | |
| 73 | + | if seg == "" || seg == "." || seg == ".." || len(seg) > maxPathElemLen || strings.ContainsFunc(seg, badPathRune) { | |
| 74 | + | return bad() | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // badPathRune: control characters (NUL included) and the backslash. Invalid | |
| 81 | + | // UTF-8 is refused for the whole value by utf8.ValidString; a literal U+FFFD is a | |
| 82 | + | // valid character. | |
| 83 | + | func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' } | |
| 84 | + | ||
| 85 | + | func cut(s string) string { | |
| 86 | + | if len(s) > 80 { | |
| 87 | + | return s[:80] + "..." | |
| 88 | + | } | |
| 89 | + | return s | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func badSegmentRune(r rune) bool { | |
| 93 | + | return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.' | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // badRefRune: a ref is a branch, a tag or a sha; no control characters, spaces | |
| 97 | + | // or characters git refuses. | |
| 98 | + | func badRefRune(r rune) bool { | |
| 99 | + | return r <= ' ' || r == 0x7f || strings.ContainsRune("~^:?*[\\@", r) // "@": a "uses" has exactly one (action.ParseUses) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // resolveActions answers a ResolveRequest of the executor with | |
| 103 | + | // RunnerService.ResolveActions. The job token stays here: the executor only | |
| 104 | + | // gets what the server returns (the per-archive tokens included, which it | |
| 105 | + | // masks). Requests come one at a time (see execstream.JobHandler.Resolve). What | |
| 106 | + | // was resolved before in the job comes from the cache; the rest goes to the | |
| 107 | + | // server in calls of at most maxResolveRefs, under the budget above. A call that | |
| 108 | + | // fails without a verdict is retried like the other job calls; a failure of the | |
| 109 | + | // call itself is reported to the executor, which fails "Set up job" | |
| 110 | + | // (ActionManager: FailedToResolveActionDownloadInfoException). ctx ends with the | |
| 111 | + | // session. | |
| 112 | + | func (j *jobRun) resolveActions(ctx context.Context, req *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 113 | + | fail := func(format string, a ...any) *execpb.ResolveResponse { | |
| 114 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)} | |
| 115 | + | } | |
| 116 | + | if len(req.Uses) == 0 { | |
| 117 | + | return fail("an empty request for action resolution") | |
| 118 | + | } | |
| 119 | + | if len(req.Uses) > maxRequestRefs { | |
| 120 | + | return fail("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxRequestRefs) | |
| 121 | + | } | |
| 122 | + | for _, u := range req.Uses { | |
| 123 | + | if err := checkUses(u); err != nil { | |
| 124 | + | return fail("%v", err) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | // The job ending, or the session, stops the calls. | |
| 128 | + | ctx, cancel := context.WithCancel(ctx) | |
| 129 | + | defer cancel() | |
| 130 | + | stop := context.AfterFunc(j.ctx, cancel) | |
| 131 | + | defer stop() | |
| 132 | + | ||
| 133 | + | got := map[string]*execpb.ResolvedAction{} | |
| 134 | + | var pending []string | |
| 135 | + | j.mu.Lock() | |
| 136 | + | for _, u := range req.Uses { | |
| 137 | + | if _, dup := got[u]; dup { | |
| 138 | + | continue | |
| 139 | + | } | |
| 140 | + | if c, ok := j.resolved[u]; ok { | |
| 141 | + | got[u] = c | |
| 142 | + | continue | |
| 143 | + | } | |
| 144 | + | got[u] = nil | |
| 145 | + | pending = append(pending, u) | |
| 146 | + | } | |
| 147 | + | j.mu.Unlock() | |
| 148 | + | for len(pending) > 0 { | |
| 149 | + | n := min(len(pending), maxResolveRefs) | |
| 150 | + | chunk := pending[:n] | |
| 151 | + | pending = pending[n:] | |
| 152 | + | j.mu.Lock() | |
| 153 | + | j.resolveRounds++ | |
| 154 | + | j.resolveRefs += len(chunk) | |
| 155 | + | over := j.resolveRounds > maxResolveRounds || j.resolveRefs > maxResolveRefsTotal | |
| 156 | + | j.mu.Unlock() | |
| 157 | + | if over { | |
| 158 | + | return fail("this job asked to resolve too many actions (the limits are %d requests and %d distinct references)", maxResolveRounds, maxResolveRefsTotal) | |
| 159 | + | } | |
| 160 | + | res, errResp := j.callResolve(ctx, chunk) | |
| 161 | + | if errResp != nil { | |
| 162 | + | return errResp | |
| 163 | + | } | |
| 164 | + | j.mu.Lock() | |
| 165 | + | for i, u := range chunk { | |
| 166 | + | got[u] = res[i] | |
| 167 | + | if res[i].Error == "" { // an error is not cached: it may be a passing one | |
| 168 | + | if j.resolved == nil { | |
| 169 | + | j.resolved = map[string]*execpb.ResolvedAction{} | |
| 170 | + | } | |
| 171 | + | j.resolved[u] = res[i] | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | j.mu.Unlock() | |
| 175 | + | } | |
| 176 | + | out := &execpb.ResolveResponse{} | |
| 177 | + | for _, u := range req.Uses { | |
| 178 | + | out.Actions = append(out.Actions, got[u]) | |
| 179 | + | } | |
| 180 | + | return out | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // callResolve makes one call to the server, with retries, and converts the | |
| 184 | + | // answer; the second result is the error to send to the executor. | |
| 185 | + | func (j *jobRun) callResolve(ctx context.Context, uses []string) ([]*execpb.ResolvedAction, *execpb.ResolveResponse) { | |
| 186 | + | fail := func(format string, a ...any) ([]*execpb.ResolvedAction, *execpb.ResolveResponse) { | |
| 187 | + | return nil, &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)} | |
| 188 | + | } | |
| 189 | + | refs := make([]*ermite.ActionRef, len(uses)) | |
| 190 | + | for i, u := range uses { | |
| 191 | + | refs[i] = &ermite.ActionRef{Uses: u} | |
| 192 | + | } | |
| 193 | + | b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax} | |
| 194 | + | for attempt := 1; ; attempt++ { | |
| 195 | + | cctx, ccancel := context.WithTimeout(ctx, j.t.CallTimeout) | |
| 196 | + | resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs})) | |
| 197 | + | ccancel() | |
| 198 | + | if err == nil { | |
| 199 | + | conv := j.resolveResponse(resp.Msg, len(uses)) | |
| 200 | + | if conv.Error != "" { | |
| 201 | + | return fail("%s", conv.Error) | |
| 202 | + | } | |
| 203 | + | return conv.Actions, nil | |
| 204 | + | } | |
| 205 | + | if ctx.Err() != nil { | |
| 206 | + | return fail("the job was cancelled while its actions were resolved") | |
| 207 | + | } | |
| 208 | + | switch classify(err) { | |
| 209 | + | case kindEnded, kindAbandoned: | |
| 210 | + | j.abandon(err) | |
| 211 | + | return fail("the server ended the job") | |
| 212 | + | case kindPermanent: | |
| 213 | + | return fail("resolving actions failed: %s", connect.CodeOf(err)) | |
| 214 | + | } | |
| 215 | + | j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed") | |
| 216 | + | if attempt >= 3 || !sleep(ctx, b.next()) { | |
| 217 | + | return fail("resolving actions failed: %s", connect.CodeOf(err)) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // resolveResponse converts the answer of the server. The token of an archive is | |
| 223 | + | // a secret that the sandbox sends in an Authorization header, so it only goes | |
| 224 | + | // with an archive of the ermite source whose tarball URL is on an origin this | |
| 225 | + | // host knows to be ermite: the public origin of the job (github.server_url) or | |
| 226 | + | // the origin of the runner's own URL. The origin the sandbox must send it to is | |
| 227 | + | // AuthOrigin; with guest forge access the tarball URL on the public origin is | |
| 228 | + | // rewritten to the runner's, like the URLs of the contexts. A token that does | |
| 229 | + | // not satisfy this is dropped (the download then fails like an unauthorised | |
| 230 | + | // one). | |
| 231 | + | func (j *jobRun) resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse { | |
| 232 | + | if len(m.Actions) != want { | |
| 233 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)} | |
| 234 | + | } | |
| 235 | + | out := &execpb.ResolveResponse{} | |
| 236 | + | for _, a := range m.Actions { | |
| 237 | + | ra := &execpb.ResolvedAction{ | |
| 238 | + | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha, | |
| 239 | + | TarballUrl: a.TarballUrl, Source: a.Source.String(), Error: a.Error, | |
| 240 | + | } | |
| 241 | + | if a.Source == ermite.ActionSource_ACTION_SOURCE_ERMITE { | |
| 242 | + | if origin := originOf(a.TarballUrl); origin != "" { | |
| 243 | + | if j.guestForge != "" && origin == j.publicOrigin { | |
| 244 | + | ra.TarballUrl = j.guestForge + strings.TrimPrefix(a.TarballUrl, a.TarballUrl[:strings.Index(a.TarballUrl, "://")+3+hostLen(a.TarballUrl)]) | |
| 245 | + | origin = j.guestForge | |
| 246 | + | } | |
| 247 | + | if a.Token != "" && (origin == j.guestForge || origin == j.publicOrigin || origin == j.serverOrigin) { | |
| 248 | + | ra.Token, ra.AuthOrigin = a.Token, origin | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | out.Actions = append(out.Actions, ra) | |
| 253 | + | } | |
| 254 | + | return out | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // setOrigins records the origins that are ermite for this job: the public one | |
| 258 | + | // (the origin of github.server_url as the server sent it), the runner's own | |
| 259 | + | // (ERMITE_RUNNER_URL) and the guest forge origin, "" when it is off. | |
| 260 | + | func (j *jobRun) setOrigins(contexts, serverURL, guestForge string) { | |
| 261 | + | j.serverOrigin = originOf(serverURL) | |
| 262 | + | j.guestForge = guestForge | |
| 263 | + | if guestForge != "" { | |
| 264 | + | j.guestForge = originOf(guestForge) | |
| 265 | + | } | |
| 266 | + | if top, err := parseObject([]byte(contexts)); err == nil { | |
| 267 | + | for _, m := range top { | |
| 268 | + | if m.key != "github" { | |
| 269 | + | continue | |
| 270 | + | } | |
| 271 | + | gh, err := parseObject(m.val) | |
| 272 | + | if err != nil { | |
| 273 | + | break | |
| 274 | + | } | |
| 275 | + | for _, g := range gh { | |
| 276 | + | var s string | |
| 277 | + | if g.key == "server_url" && json.Unmarshal(g.val, &s) == nil { | |
| 278 | + | j.publicOrigin = originOf(s) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // SetGitHubToken returns the contexts JSON of a job with github.token set to | |
| 286 | + | // token (the job repository token, Job.github_token). The key is added first, | |
| 287 | + | // like the runner does (ExecutionContext.cs:1023: token, then what the server | |
| 288 | + | // gave); an empty token leaves the contexts as they are. | |
| 289 | + | func SetGitHubToken(contexts, token string) (string, error) { | |
| 290 | + | if token == "" { | |
| 291 | + | return contexts, nil | |
| 292 | + | } | |
| 293 | + | top, err := parseObject([]byte(contexts)) | |
| 294 | + | if err != nil { | |
| 295 | + | return "", fmt.Errorf("job contexts: %w", err) | |
| 296 | + | } | |
| 297 | + | gi := -1 | |
| 298 | + | for i, m := range top { | |
| 299 | + | if m.key == "github" { | |
| 300 | + | gi = i | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | var gh []jsonMember | |
| 304 | + | if gi >= 0 { | |
| 305 | + | if gh, err = parseObject(top[gi].val); err != nil { | |
| 306 | + | return "", fmt.Errorf("job contexts: github: %w", err) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | tv, err := json.Marshal(token) | |
| 310 | + | if err != nil { | |
| 311 | + | return "", err | |
| 312 | + | } | |
| 313 | + | set := false | |
| 314 | + | for i, m := range gh { | |
| 315 | + | if m.key == "token" { | |
| 316 | + | gh[i].val, set = tv, true | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | if !set { | |
| 320 | + | gh = append([]jsonMember{{"token", tv}}, gh...) | |
| 321 | + | } | |
| 322 | + | ghv, err := encodeObject(gh) | |
| 323 | + | if err != nil { | |
| 324 | + | return "", err | |
| 325 | + | } | |
| 326 | + | if gi >= 0 { | |
| 327 | + | top[gi].val = ghv | |
| 328 | + | } else { | |
| 329 | + | top = append(top, jsonMember{"github", ghv}) | |
| 330 | + | } | |
| 331 | + | out, err := encodeObject(top) | |
| 332 | + | return string(out), err | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | // hostLen is the length of the authority of an absolute URL. | |
| 336 | + | func hostLen(u string) int { | |
| 337 | + | rest := u[strings.Index(u, "://")+3:] | |
| 338 | + | if i := strings.IndexAny(rest, "/?#"); i >= 0 { | |
| 339 | + | return i | |
| 340 | + | } | |
| 341 | + | return len(rest) | |
| 342 | + | } | |
A internal/host/resolve_test.go +305 -0
| @@ -0,0 +1,305 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package host | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "connectrpc.com/connect" | |
| 14 | + | "github.com/bnema/zerowrap" | |
| 15 | + | ||
| 16 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | type resolveStub struct { | |
| 22 | + | stubClient | |
| 23 | + | got *ermite.ResolveActionsRequest | |
| 24 | + | resp *ermite.ResolveActionsResponse | |
| 25 | + | err error | |
| 26 | + | fn func(*ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse | |
| 27 | + | onCall func(*ermite.ResolveActionsRequest) | |
| 28 | + | calls int | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) { | |
| 32 | + | c.got = r.Msg | |
| 33 | + | c.calls++ | |
| 34 | + | if c.onCall != nil { | |
| 35 | + | c.onCall(r.Msg) | |
| 36 | + | } | |
| 37 | + | if c.err != nil { | |
| 38 | + | return nil, c.err | |
| 39 | + | } | |
| 40 | + | if c.fn != nil { | |
| 41 | + | return connect.NewResponse(c.fn(r.Msg)), nil | |
| 42 | + | } | |
| 43 | + | return connect.NewResponse(c.resp), nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func resolveJob(c jobClient) *jobRun { | |
| 47 | + | j := &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{}, | |
| 48 | + | t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}} | |
| 49 | + | j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://ermite.example", "") | |
| 50 | + | return j | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestResolveActions(t *testing.T) { | |
| 54 | + | c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{ | |
| 55 | + | {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://ermite.example/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE}, | |
| 56 | + | {Ref: "a/c@v1", Error: "boom"}, | |
| 57 | + | }}} | |
| 58 | + | resp := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}}) | |
| 59 | + | if resp.Error != "" || len(resp.Actions) != 2 { | |
| 60 | + | t.Fatalf("%+v", resp) | |
| 61 | + | } | |
| 62 | + | if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" { | |
| 63 | + | t.Errorf("request %+v", c.got) | |
| 64 | + | } | |
| 65 | + | if a := resp.Actions[0]; a.Token != "tok" || a.AuthOrigin != "https://ermite.example" || a.TarballUrl != "https://ermite.example/t" || a.NameWithOwner != "a/b" { | |
| 66 | + | t.Errorf("%+v", a) | |
| 67 | + | } | |
| 68 | + | if resp.Actions[1].Error != "boom" { | |
| 69 | + | t.Errorf("%+v", resp.Actions[1]) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestResolveActionsFailures(t *testing.T) { | |
| 74 | + | c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)} | |
| 75 | + | if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") { | |
| 76 | + | t.Errorf("%+v", r) | |
| 77 | + | } | |
| 78 | + | c = &resolveStub{resp: &ermite.ResolveActionsResponse{}} | |
| 79 | + | if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" { | |
| 80 | + | t.Errorf("a short answer must fail: %+v", r) | |
| 81 | + | } | |
| 82 | + | many := make([]string, maxResolveRefs+1) | |
| 83 | + | if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many}); r.Error == "" { | |
| 84 | + | t.Error("more than 100 refs must fail") | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestSetGitHubToken(t *testing.T) { | |
| 89 | + | cases := map[string]string{ | |
| 90 | + | `{"github":{"job":"j","token":""},"needs":{}}`: `{"github":{"job":"j","token":"T"},"needs":{}}`, | |
| 91 | + | `{"github":{"job":"j"}}`: `{"github":{"token":"T","job":"j"}}`, | |
| 92 | + | `{"needs":{}}`: `{"needs":{},"github":{"token":"T"}}`, | |
| 93 | + | } | |
| 94 | + | for in, want := range cases { | |
| 95 | + | got, err := SetGitHubToken(in, "T") | |
| 96 | + | if err != nil || got != want { | |
| 97 | + | t.Errorf("%s: %s %v, want %s", in, got, err, want) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | if got, _ := SetGitHubToken(`{"a":1}`, ""); got != `{"a":1}` { | |
| 101 | + | t.Errorf("empty token changed the contexts: %s", got) | |
| 102 | + | } | |
| 103 | + | if _, err := SetGitHubToken(`[]`, "T"); err == nil { | |
| 104 | + | t.Error("not an object") | |
| 105 | + | } | |
| 106 | + | var m map[string]any | |
| 107 | + | if got, _ := SetGitHubToken(`{}`, `a"b\`); json.Unmarshal([]byte(got), &m) != nil { | |
| 108 | + | t.Errorf("invalid JSON: %s", got) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestCheckUses(t *testing.T) { | |
| 113 | + | long := func(n int) string { return strings.Repeat("a", n) } | |
| 114 | + | for u, ok := range map[string]bool{ | |
| 115 | + | "actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true, | |
| 116 | + | // GO-011: the path of the action is a path, not a repository name. | |
| 117 | + | "o/r/action+tools@v1": true, "o/r/déploiement/日本語@v1": true, "o/r/with space/x@v1": true, "o/r/a(b)[c]{d}~e#f%g@v1": true, | |
| 118 | + | "o/r/a\ufffdb@v1": true, "o/r/\ufffd@v1": true, // a literal U+FFFD is a valid character | |
| 119 | + | "o/r/" + long(255) + "@v1": true, "o/r/.github/actions/x@v1": true, "o/r/.hidden@v1": true, "o/r/a..b@v1": true, | |
| 120 | + | "": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false, | |
| 121 | + | "o/r/a/../b@v1": false, "o/r/./a@v1": false, "o/r/a//b@v1": false, "o/r/a/@v1": false, | |
| 122 | + | "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false, "o/r/a\xc3(b@v1": false, "o/r/\xed\xa0\x80@v1": false, | |
| 123 | + | "o/r/" + long(256) + "@v1": false, long(101) + "/r@v1": false, "o/" + long(101) + "@v1": false, | |
| 124 | + | "o/r@" + long(256): false, "o/r/" + strings.Repeat("a/", 600) + "x@v1": false, | |
| 125 | + | "o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false, | |
| 126 | + | "o/r\x00@v1": false, "ö/r@v1": false, "o/r+x@v1": false, "o/r:x@v1": false, | |
| 127 | + | } { | |
| 128 | + | if err := checkUses(u); (err == nil) != ok { | |
| 129 | + | t.Errorf("%q: %v, want ok=%v", cut(u), err, ok) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // GO-003: whatever the sandbox sends, the server is called with valid | |
| 135 | + | // references only, and with a bounded number of calls and references. | |
| 136 | + | func TestResolveActionsRejectsAndBudgets(t *testing.T) { | |
| 137 | + | c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: "a/b@v1"}}}} | |
| 138 | + | j := resolveJob(c) | |
| 139 | + | for _, uses := range [][]string{nil, {}, {""}, {"nonsense"}, {"a/b@v1", "bad ref"}} { | |
| 140 | + | c.got = nil | |
| 141 | + | if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: uses}); r.Error == "" || c.got != nil { | |
| 142 | + | t.Errorf("%q: error %q, called=%v", uses, r.Error, c.got != nil) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | // Distinct references, one per request: the rounds are the limit. | |
| 146 | + | c.resp = nil | |
| 147 | + | j = resolveJob(c) | |
| 148 | + | c.fn = func(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse { return echo(r) } | |
| 149 | + | var calls int | |
| 150 | + | for i := 0; i < maxResolveRounds+20; i++ { | |
| 151 | + | c.got = nil | |
| 152 | + | r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{fmt.Sprintf("a/b%d@v1", i)}}) | |
| 153 | + | if c.got != nil { | |
| 154 | + | calls++ | |
| 155 | + | } | |
| 156 | + | if i >= maxResolveRounds && r.Error == "" { | |
| 157 | + | t.Fatalf("round %d is over the budget and was served", i) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | if calls != maxResolveRounds { | |
| 161 | + | t.Errorf("%d calls, the budget is %d", calls, maxResolveRounds) | |
| 162 | + | } | |
| 163 | + | // Distinct references in big requests: the references are the limit. | |
| 164 | + | j = resolveJob(c) | |
| 165 | + | served := 0 | |
| 166 | + | for i := 0; i < 40; i++ { | |
| 167 | + | big := make([]string, maxResolveRefs) | |
| 168 | + | for k := range big { | |
| 169 | + | big[k] = fmt.Sprintf("a/b%d_%d@v1", i, k) | |
| 170 | + | } | |
| 171 | + | if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: big}); r.Error == "" { | |
| 172 | + | served++ | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | if served != maxResolveRefsTotal/maxResolveRefs { | |
| 176 | + | t.Errorf("served %d requests of %d refs, the budget is %d refs", served, maxResolveRefs, maxResolveRefsTotal) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func echo(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse { | |
| 181 | + | out := &ermite.ResolveActionsResponse{} | |
| 182 | + | for _, ref := range r.Refs { | |
| 183 | + | out.Actions = append(out.Actions, &ermite.ResolvedAction{Ref: ref.Uses, NameWithOwner: "a/b"}) | |
| 184 | + | } | |
| 185 | + | return out | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // GO-010: a resolution is cached for the job, so repeated invocations of a local | |
| 189 | + | // composite action cost nothing, and a big request is split into calls. | |
| 190 | + | func TestResolveActionsCacheAndBatches(t *testing.T) { | |
| 191 | + | c := &resolveStub{} | |
| 192 | + | c.fn = echo | |
| 193 | + | j := resolveJob(c) | |
| 194 | + | for i := 0; i < 500; i++ { | |
| 195 | + | r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1", "a/c/sub@v2"}}) | |
| 196 | + | if r.Error != "" || len(r.Actions) != 2 || r.Actions[1].Ref != "a/c/sub@v2" { | |
| 197 | + | t.Fatalf("invocation %d: %+v", i, r) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | if c.calls != 1 { | |
| 201 | + | t.Errorf("%d calls for 500 invocations of the same actions", c.calls) | |
| 202 | + | } | |
| 203 | + | // A new action goes through; only it is asked. | |
| 204 | + | c.got = nil | |
| 205 | + | if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1", "a/new@v1"}}); r.Error != "" || len(c.got.Refs) != 1 || c.got.Refs[0].Uses != "a/new@v1" { | |
| 206 | + | t.Errorf("%+v %v", r, c.got) | |
| 207 | + | } | |
| 208 | + | // Duplicates in one request are asked once, answered in order. | |
| 209 | + | j = resolveJob(c) | |
| 210 | + | c.calls = 0 | |
| 211 | + | r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/x@v1", "a/y@v1", "a/x@v1"}}) | |
| 212 | + | if r.Error != "" || len(r.Actions) != 3 || r.Actions[2].Ref != "a/x@v1" || r.Actions[1].Ref != "a/y@v1" || c.calls != 1 || len(c.got.Refs) != 2 { | |
| 213 | + | t.Errorf("%+v calls %d", r, c.calls) | |
| 214 | + | } | |
| 215 | + | // 250 distinct references: three calls of at most 100, in order. | |
| 216 | + | j = resolveJob(c) | |
| 217 | + | c.calls = 0 | |
| 218 | + | var sizes []int | |
| 219 | + | c.onCall = func(r *ermite.ResolveActionsRequest) { sizes = append(sizes, len(r.Refs)) } | |
| 220 | + | many := make([]string, 250) | |
| 221 | + | for i := range many { | |
| 222 | + | many[i] = fmt.Sprintf("a/b%d@v1", i) | |
| 223 | + | } | |
| 224 | + | r = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many}) | |
| 225 | + | if r.Error != "" || len(r.Actions) != 250 || r.Actions[249].Ref != many[249] || fmt.Sprint(sizes) != "[100 100 50]" { | |
| 226 | + | t.Errorf("%q calls %v", r.Error, sizes) | |
| 227 | + | } | |
| 228 | + | // An error of one action is not cached. | |
| 229 | + | c.fn = func(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse { | |
| 230 | + | return &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: r.Refs[0].Uses, Error: "not found"}}} | |
| 231 | + | } | |
| 232 | + | j = resolveJob(c) | |
| 233 | + | c.calls = 0 | |
| 234 | + | _ = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/gone@v1"}}) | |
| 235 | + | _ = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/gone@v1"}}) | |
| 236 | + | if c.calls != 2 { | |
| 237 | + | t.Errorf("an error was cached: %d calls", c.calls) | |
| 238 | + | } | |
| 239 | + | // More than the request limit. | |
| 240 | + | if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: make([]string, maxRequestRefs+1)}); r.Error == "" { | |
| 241 | + | t.Error("a request over the limit was served") | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // GO-002 on the host side: the token only goes with an ermite archive whose URL | |
| 246 | + | // is on an origin of ermite; with guest forge access the URL is rewritten. | |
| 247 | + | func TestResolveResponseTokenOrigin(t *testing.T) { | |
| 248 | + | j := resolveJob(&resolveStub{}) | |
| 249 | + | ermiteSrc, ghSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE, ermite.ActionSource_ACTION_SOURCE_GITHUB | |
| 250 | + | conv := func(j *jobRun, src ermite.ActionSource, url, tok string) *execpb.ResolvedAction { | |
| 251 | + | r := j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: src, TarballUrl: url, Token: tok}}}, 1) | |
| 252 | + | return r.Actions[0] | |
| 253 | + | } | |
| 254 | + | if a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t"); a.Token != "t" || a.AuthOrigin != "https://ermite.example" { | |
| 255 | + | t.Errorf("%+v", a) | |
| 256 | + | } | |
| 257 | + | for name, a := range map[string]*execpb.ResolvedAction{ | |
| 258 | + | "github source": conv(j, ghSrc, "https://ermite.example/x", "t"), | |
| 259 | + | "foreign origin": conv(j, ermiteSrc, "https://evil.example/x", "t"), | |
| 260 | + | "subdomain": conv(j, ermiteSrc, "https://x.ermite.example/x", "t"), | |
| 261 | + | "other port": conv(j, ermiteSrc, "https://ermite.example:8443/x", "t"), | |
| 262 | + | "other scheme": conv(j, ermiteSrc, "http://ermite.example/x", "t"), | |
| 263 | + | "not a url": conv(j, ermiteSrc, "ermite.example/x", "t"), | |
| 264 | + | "credentials in it": conv(j, ermiteSrc, "https://u:p@ermite.example/x", "t"), | |
| 265 | + | } { | |
| 266 | + | if a.Token != "" || a.AuthOrigin != "" { | |
| 267 | + | t.Errorf("%s: token kept: %+v", name, a) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | // No token, no origin: github.com archives are anonymous. | |
| 271 | + | if a := conv(j, ghSrc, "https://codeload.github.com/o/r/tar.gz/abc", ""); a.Token != "" || a.AuthOrigin != "" { | |
| 272 | + | t.Errorf("%+v", a) | |
| 273 | + | } | |
| 274 | + | // Guest forge access: the sandbox reaches ermite at the runner's origin. | |
| 275 | + | j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://runner.internal:8088", "http://10.0.2.2:8088") | |
| 276 | + | a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t") | |
| 277 | + | if a.TarballUrl != "http://10.0.2.2:8088/-/github/api/repos/o/r/tarball/abc" || a.AuthOrigin != "http://10.0.2.2:8088" || a.Token != "t" { | |
| 278 | + | t.Errorf("%+v", a) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // GO-012 on the archive tokens: with IPv6 hosts the origin named for the token is | |
| 283 | + | // exact, and a look-alike is not accepted. | |
| 284 | + | func TestResolveResponseTokenOriginIPv6(t *testing.T) { | |
| 285 | + | j := resolveJob(&resolveStub{}) | |
| 286 | + | j.setOrigins(`{"github":{"server_url":"https://[::1]:8443"}}`, "https://[fd00::9]", "http://[fd00::2]:8088") | |
| 287 | + | ermiteSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE | |
| 288 | + | conv := func(url string) *execpb.ResolvedAction { | |
| 289 | + | return j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: ermiteSrc, TarballUrl: url, Token: "t"}}}, 1).Actions[0] | |
| 290 | + | } | |
| 291 | + | // The public origin is rewritten to the guest one. | |
| 292 | + | if a := conv("https://[::1]:8443/-/github/api/repos/o/r/tarball/abc"); a.Token != "t" || a.AuthOrigin != "http://[fd00::2]:8088" || a.TarballUrl != "http://[fd00::2]:8088/-/github/api/repos/o/r/tarball/abc" { | |
| 293 | + | t.Errorf("%+v", a) | |
| 294 | + | } | |
| 295 | + | // The runner's own origin, default port. | |
| 296 | + | if a := conv("https://[fd00::9]:443/x"); a.Token != "t" || a.AuthOrigin != "https://[fd00::9]" { | |
| 297 | + | t.Errorf("%+v", a) | |
| 298 | + | } | |
| 299 | + | // The look-alike host "::1:8443" and other ports are not ermite. | |
| 300 | + | for _, u := range []string{"https://[::1:8443]/x", "https://[::1]:8444/x", "http://[::1]:8443/x", "https://[::1]/x"} { | |
| 301 | + | if a := conv(u); a.Token != "" || a.AuthOrigin != "" { | |
| 302 | + | t.Errorf("%s: token kept: %+v", u, a) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | } | |
M internal/host/shutdown_test.go +4 -0
| @@ -50,6 +50,10 @@ func (c *stubClient) WatchJob(context.Context, *connect.Request[ermite.WatchJobR | |||
| 50 | 50 | return nil, connect.NewError(connect.CodeUnimplemented, nil) | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | + | func (c *stubClient) ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) { | |
| 54 | + | return nil, connect.NewError(connect.CodeUnimplemented, nil) | |
| 55 | + | } | |
| 56 | + | ||
| 53 | 57 | func (c *stubClient) CompleteJob(ctx context.Context, _ *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) { | |
| 54 | 58 | c.mu.Lock() | |
| 55 | 59 | c.deadline, _ = ctx.Deadline() | |
A internal/urlorigin/urlorigin.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package urlorigin gives the one canonical form of the origin of an http(s) | |
| 4 | + | // URL that the host and the executor both compare: the archive token of an | |
| 5 | + | // action goes only to the origin the host named, so the two sides must agree on | |
| 6 | + | // what "the same origin" means, whatever the spelling of the URL. | |
| 7 | + | package urlorigin | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "net" | |
| 11 | + | "net/url" | |
| 12 | + | "strings" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Canonical returns "scheme://host:port" for an absolute http or https URL: the | |
| 16 | + | // scheme and the host lower-cased, the default port (80, 443) filled in, an IPv6 | |
| 17 | + | // host in brackets (net.JoinHostPort), so that "https://[::1]:8443/x" and | |
| 18 | + | // "https://[::1:8443]/x" (a different host, without a port) differ, and | |
| 19 | + | // "https://a.test" and "https://a.test:443" are the same. It returns "" for | |
| 20 | + | // anything else, including a URL with credentials, which never carries a token. | |
| 21 | + | func Canonical(raw string) string { | |
| 22 | + | u, err := url.Parse(raw) | |
| 23 | + | if err != nil || u.Host == "" || u.User != nil { | |
| 24 | + | return "" | |
| 25 | + | } | |
| 26 | + | scheme := strings.ToLower(u.Scheme) | |
| 27 | + | port, ok := effectivePort(scheme, u.Port()) | |
| 28 | + | if !ok || u.Hostname() == "" { | |
| 29 | + | return "" | |
| 30 | + | } | |
| 31 | + | return scheme + "://" + net.JoinHostPort(strings.ToLower(u.Hostname()), port) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func effectivePort(scheme, port string) (string, bool) { | |
| 35 | + | switch { | |
| 36 | + | case port != "": | |
| 37 | + | if scheme != "http" && scheme != "https" { | |
| 38 | + | return "", false | |
| 39 | + | } | |
| 40 | + | return port, true | |
| 41 | + | case scheme == "http": | |
| 42 | + | return "80", true | |
| 43 | + | case scheme == "https": | |
| 44 | + | return "443", true | |
| 45 | + | } | |
| 46 | + | return "", false | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Prefix returns the origin as it is written at the start of a URL: the same | |
| 50 | + | // as Canonical without the default port, which is how a URL is built from it. | |
| 51 | + | func Prefix(raw string) string { | |
| 52 | + | c := Canonical(raw) | |
| 53 | + | switch { | |
| 54 | + | case strings.HasPrefix(c, "http://") && strings.HasSuffix(c, ":80"): | |
| 55 | + | return strings.TrimSuffix(c, ":80") | |
| 56 | + | case strings.HasPrefix(c, "https://") && strings.HasSuffix(c, ":443"): | |
| 57 | + | return strings.TrimSuffix(c, ":443") | |
| 58 | + | } | |
| 59 | + | return c | |
| 60 | + | } | |
A internal/urlorigin/urlorigin_test.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package urlorigin | |
| 4 | + | ||
| 5 | + | import "testing" | |
| 6 | + | ||
| 7 | + | func TestCanonical(t *testing.T) { | |
| 8 | + | for in, want := range map[string]string{ | |
| 9 | + | "https://Ermite.example/x": "https://ermite.example:443", | |
| 10 | + | "HTTPS://ermite.example": "https://ermite.example:443", | |
| 11 | + | "https://ermite.example:443/x": "https://ermite.example:443", | |
| 12 | + | "http://a.test:80": "http://a.test:80", | |
| 13 | + | "http://a.test": "http://a.test:80", | |
| 14 | + | "http://a.test:8088/p?q#f": "http://a.test:8088", | |
| 15 | + | "https://[::1]:8443/x": "https://[::1]:8443", | |
| 16 | + | "https://[::1]/x": "https://[::1]:443", | |
| 17 | + | "https://[::1]:443": "https://[::1]:443", | |
| 18 | + | "https://[2001:DB8::1]:8443": "https://[2001:db8::1]:8443", | |
| 19 | + | "http://[::1]:80": "http://[::1]:80", | |
| 20 | + | "http://10.0.2.2:8088": "http://10.0.2.2:8088", | |
| 21 | + | "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "https://u:p@a.test": "", "": "", "https://": "", "//a.test": "", | |
| 22 | + | "https://[::1:8443]/x": "https://[::1:8443]:443", // an IPv6 host with no port at all | |
| 23 | + | } { | |
| 24 | + | if got := Canonical(in); got != want { | |
| 25 | + | t.Errorf("Canonical(%q) = %q, want %q", in, got, want) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | // GO-012: the host ::1 on port 8443 is not the host ::1:8443. | |
| 29 | + | if Canonical("https://[::1]:8443/x") == Canonical("https://[::1:8443]/x") { | |
| 30 | + | t.Error("two different origins collide") | |
| 31 | + | } | |
| 32 | + | // Spellings of one origin agree. | |
| 33 | + | for _, pair := range [][2]string{ | |
| 34 | + | {"https://a.test", "https://A.TEST:443/path"}, {"http://a.test:80/", "http://a.test"}, {"https://[::1]", "https://[::1]:443"}, | |
| 35 | + | } { | |
| 36 | + | if Canonical(pair[0]) != Canonical(pair[1]) { | |
| 37 | + | t.Errorf("%q and %q differ: %q %q", pair[0], pair[1], Canonical(pair[0]), Canonical(pair[1])) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | // Other ports, schemes and hosts differ. | |
| 41 | + | for _, pair := range [][2]string{ | |
| 42 | + | {"https://a.test", "http://a.test"}, {"https://a.test", "https://a.test:8443"}, {"https://a.test", "https://b.a.test"}, {"http://a.test", "http://a.test:443"}, | |
| 43 | + | } { | |
| 44 | + | if Canonical(pair[0]) == Canonical(pair[1]) { | |
| 45 | + | t.Errorf("%q and %q collide", pair[0], pair[1]) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestPrefix(t *testing.T) { | |
| 51 | + | for in, want := range map[string]string{ | |
| 52 | + | "https://Ermite.example:443/x": "https://ermite.example", "http://a.test:80": "http://a.test", "http://a.test:8088": "http://a.test:8088", | |
| 53 | + | "https://[::1]:443": "https://[::1]", "http://[::1]:80/": "http://[::1]", "https://[::1]:8443": "https://[::1]:8443", | |
| 54 | + | "http://10.0.2.2:8088": "http://10.0.2.2:8088", "https://a.test:80": "https://a.test:80", "ftp://x": "", | |
| 55 | + | } { | |
| 56 | + | if got := Prefix(in); got != want { | |
| 57 | + | t.Errorf("Prefix(%q) = %q, want %q", in, got, want) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |